diff --git a/.claude/skills/rust-test-hygiene/SKILL.md b/.claude/skills/rust-test-hygiene/SKILL.md new file mode 100644 index 00000000..0ad60a75 --- /dev/null +++ b/.claude/skills/rust-test-hygiene/SKILL.md @@ -0,0 +1,141 @@ +--- +name: rust-test-hygiene +description: >- + Apply treq's Rust test-suite standards when writing, reviewing, or cleaning + up tests under src-tauri/ or crates/. Use when the user mentions "rust + tests", "src-tauri/tests", "cargo test", "test hygiene", asks to review or + add a Rust test, or asks to clean up/consolidate/dedupe Rust test coverage. + Checks that a test asserts something real about treq (not the host OS, not + serde itself, not a #[cfg(test)]-only helper), reuses TestRepo helpers from + src-tauri/tests/e2e_test_helpers.rs instead of hand-rolled setup, avoids + duplicating coverage that already exists elsewhere in the file or repo, and + merges near-identical test clusters into a table-driven test instead of + copy-pasting. +--- + +# Rust test hygiene + +## When this runs + +- Writing a new `#[test]` under `src-tauri/` or `crates/`. +- Reviewing a PR/diff that adds or changes Rust tests. +- Asked to clean up, consolidate, or dedupe Rust test coverage. + +## 1. Does this test assert anything about treq? + +Before writing the assertion, ask what would have to be broken in *treq's own +code* for this test to fail. If nothing in `src-tauri/src/**` or +`crates/**` (excluding `#[cfg(test)]` blocks) has to run, don't write it. + +Reject/flag tests that: + +- **Check the host environment**, not treq: e.g. asserting `git` or a binary + is discoverable on `$PATH`, or that a path contains `/opt/homebrew/bin`. + These pass or fail based on the machine, not the code. +- **Round-trip serde on a struct with no treq logic in the path.** A test + that does `serde_json::from_str::(literal)` and only checks the + fields came back is testing serde, not treq — unless `Foo`'s + `Deserialize` has custom logic (validation, defaulting, renames) that the + test is specifically targeting. +- **Exercise a function that only exists for tests to call.** If the target + function is `#[cfg(test)]`-gated (or `pub(super)`/private and reachable + only from the test module) with zero production call sites, the test is + asserting the helper matches itself. Either the helper should be deleted, + or the test should drive it through the real production entry point + instead. + +Keep tests that exercise a real production code path — `core::*`, +`treq_lib::jj::*`, `dispatch.rs` handlers, CLI arg parsing that +`main`/Tauri commands actually call, etc. — even if the assertion is small. + +## 2. Reuse `TestRepo` — don't hand-roll e2e/integration setup + +Before writing setup code in `src-tauri/tests/*.rs`, check +`src-tauri/tests/e2e_test_helpers.rs` for an existing helper. It already +covers: + +- Repo/workspace creation: `TestRepo::new()`, `TestRepo::new_without_init()`, + `TestRepo::with_remote()`, `create_workspace_simple`, + `create_workspace_with_commit`, `setup_workspace_with_pushed_commit`. +- Commits and file writes: `commit_file`, `commit_workspace_file`, + `remote_commit_file`, `remote_commit_on_branch`, `create_file`, + `write_workspace_file`, `append_workspace_file`. +- Running the `git`/`jj` binaries: `TestRepo::run_git`, `TestRepo::run_jj` — + never shell out to `git`/`jj` directly or resolve the binary path yourself. +- Inspection: `get_log`, `get_current_bookmark`, `list_bookmarks`, + `get_bookmark_commit_id`, `has_changes`, `get_status`, + `verify_workspace_structure`, `file_exists_in_workspace`. + +If none of these fit, add the new helper to `e2e_test_helpers.rs` rather +than inlining equivalent logic in a test file — the next test that needs it +should find it there. + +## 3. Check for existing coverage before adding a test + +Before writing a new test, grep the same file (and any file covering the +same module/command) for an existing test hitting the same code path or +scenario. Cross-file duplication is easy to miss because tests get added to +whichever file feels topically closest at the time. Specifically check: + +- Does another `*_test.rs` file already cover this exact behavior under a + different name (e.g. a general workspace test file and a narrower + feature-specific one)? +- Is this "regression test for a disproven hypothesis" — i.e. does it + duplicate what a more direct/primary test already asserts, just via a + different code path that turned out not to matter? If so, the primary + assertion is enough; don't keep the disproven-hypothesis version around + as extra insurance. + +If you find real overlap, delete the weaker/narrower duplicate and keep the +one with the clearer name and the more complete assertions — don't keep both +"just in case." + +## 4. Table-driven tests over copy-paste clusters + +If you're about to write 3+ tests that are structurally identical except for +literal inputs and expected outputs, merge them into one test that loops +over a `Vec` of named cases instead. Only reach for `rstest` if it's already +a dependency (check `Cargo.toml` / `src-tauri/Cargo.toml` first — as of this +writing it is not, so default to a plain loop). + +Pattern to follow (mirrors `pty_tests.rs`): + +```rust +#[test] +fn test_strip_ansi_codes_cases() { + // Table-driven: each case is a (name, input, expected) triple. + let cases: Vec<(&str, &str, &str)> = vec![ + ("plain text is untouched", "hello world", "hello world"), + ("CSI color codes are stripped", "\x1b[32mhello\x1b[0m", "hello"), + ("OSC title-setting sequence is stripped", "\x1b]0;title\x07text", "text"), + ]; + + for (name, input, expected) in cases { + assert_eq!(strip_ansi_codes(input), expected, "case: {name}"); + } +} +``` + +For cases needing more than a couple of fields, use a `struct Case { name, +... , expected }` instead of a tuple — keep every case named descriptively +so a failure's `case: {name}` output tells you which scenario broke without +having to count array indices. + +Don't merge tests that differ in more than inputs/outputs — if the *setup +shape* or *assertion structure* differs (not just values), keep them +separate; forcing a table-driven shape onto genuinely different scenarios +makes the test harder to read than the duplication it removes. + +## 5. Quick checklist for a review pass + +- [ ] Every new/changed test would fail if the corresponding treq code + broke — not if the host machine or serde changed. +- [ ] No hand-rolled repo/workspace/commit setup that `TestRepo` already + provides. +- [ ] No new test duplicates scenario coverage already in this file or a + sibling `*_test.rs` file. +- [ ] Any cluster of 3+ near-identical tests is collapsed into one + table-driven test (or uses `rstest` if already a dependency). +- [ ] Deleted/merged tests didn't drop an assertion that was the *only* + coverage for some behavior — check the diff removes duplication, not + unique coverage. diff --git a/src-tauri/tests/core_jj_sync_status_test.rs b/src-tauri/tests/core_jj_sync_status_test.rs index c054e1b8..48f92afc 100644 --- a/src-tauri/tests/core_jj_sync_status_test.rs +++ b/src-tauri/tests/core_jj_sync_status_test.rs @@ -80,29 +80,6 @@ fn test_workspace_sync_status_local_only_multiple_commits_integration() { ); } -#[test] -fn test_workspace_sync_status_in_sync_after_push_pull_integration() { - let (repo, workspace, workspace_path_str) = setup_workspace_with_remote(); - - TestRepo::write_workspace_file(&workspace_path_str, "local-1.txt", "local content\n") - .expect("Failed to write file"); - treq_lib::core::commit_workspace(&repo.repo_path, workspace.id, "Local commit 1") - .expect("Failed to commit"); - treq_lib::core::push_workspace_to_remote(&repo.repo_path, Some(workspace.id)) - .expect("Failed to push"); - treq_lib::core::pull_workspace_from_remote(&repo.repo_path, Some(workspace.id), "git") - .expect("Failed to pull"); - - let status = workspace_status(&repo.repo_path, Some(workspace.id)) - .expect("workspace_status should succeed"); - assert_eq!( - status.remote_sync, - RemoteSyncStatus::InSync, - "expected in sync after push+pull, got {:?}", - status.remote_sync - ); -} - #[test] fn test_workspace_sync_status_true_divergence_integration() { let (repo, workspace, workspace_path_str) = setup_workspace_with_remote(); @@ -248,54 +225,6 @@ fn test_workspace_status_behind_remote() { ); } -#[test] -fn test_jj_get_sync_status_ahead_after_local_commit() { - let repo = TestRepo::with_remote().expect("Failed to create test repo with remote"); - - let workspace = treq_lib::core::create_workspace( - &repo.repo_path, - "feat/sync-ahead", - Some("sync status ahead test".to_string()), - None, - None, - None, - None, - ) - .expect("Failed to create workspace"); - - let workspace_path = repo.workspaces_dir().join(&workspace.workspace_path); - let workspace_path_str = workspace_path.to_str().unwrap(); - - // Establish remote branch - TestRepo::write_workspace_file(workspace_path_str, "initial.txt", "initial content\n") - .expect("Failed to write file"); - treq_lib::core::commit_workspace(&repo.repo_path, workspace.id, "Initial commit on branch") - .expect("Failed to commit"); - treq_lib::core::push_workspace_to_remote(&repo.repo_path, Some(workspace.id)) - .expect("Failed to push"); - treq_lib::core::pull_workspace_from_remote(&repo.repo_path, Some(workspace.id), "git") - .expect("Failed to pull"); - - // Make a local-only commit → expect (1, 0) - TestRepo::write_workspace_file(workspace_path_str, "local_only.txt", "local content\n") - .expect("Failed to write file"); - treq_lib::core::commit_workspace(&repo.repo_path, workspace.id, "Local only commit") - .expect("Failed to commit"); - - let (ahead, behind) = jj::jj_get_sync_status(workspace_path_str, &workspace.branch_name, false) - .expect("Failed to get sync status after local commit"); - assert_eq!( - ahead, 1, - "After local commit, should be 1 ahead, got {}", - ahead - ); - assert_eq!( - behind, 0, - "After local commit, should be 0 behind, got {}", - behind - ); -} - #[test] fn test_jj_get_sync_status_returns_to_sync_after_push() { let repo = TestRepo::with_remote().expect("Failed to create test repo with remote"); @@ -367,55 +296,3 @@ fn test_jj_get_sync_status_returns_to_sync_after_push() { behind ); } - -#[test] -fn test_jj_get_sync_status_multiple_commits_ahead() { - let repo = TestRepo::with_remote().expect("Failed to create test repo with remote"); - - let workspace = treq_lib::core::create_workspace( - &repo.repo_path, - "feat/sync-multi", - Some("sync status multiple ahead test".to_string()), - None, - None, - None, - None, - ) - .expect("Failed to create workspace"); - - let workspace_path = repo.workspaces_dir().join(&workspace.workspace_path); - let workspace_path_str = workspace_path.to_str().unwrap(); - - // Establish remote branch - TestRepo::write_workspace_file(workspace_path_str, "initial.txt", "initial content\n") - .expect("Failed to write file"); - treq_lib::core::commit_workspace(&repo.repo_path, workspace.id, "Initial commit on branch") - .expect("Failed to commit"); - treq_lib::core::push_workspace_to_remote(&repo.repo_path, Some(workspace.id)) - .expect("Failed to push"); - treq_lib::core::pull_workspace_from_remote(&repo.repo_path, Some(workspace.id), "git") - .expect("Failed to pull"); - - // Make two local commits → expect (2, 0) - TestRepo::write_workspace_file(workspace_path_str, "local_2.txt", "content 2\n") - .expect("Failed to write file"); - treq_lib::core::commit_workspace(&repo.repo_path, workspace.id, "Second local commit") - .expect("Failed to commit"); - TestRepo::write_workspace_file(workspace_path_str, "local_3.txt", "content 3\n") - .expect("Failed to write file"); - treq_lib::core::commit_workspace(&repo.repo_path, workspace.id, "Third local commit") - .expect("Failed to commit"); - - let (ahead, behind) = jj::jj_get_sync_status(workspace_path_str, &workspace.branch_name, false) - .expect("Failed to get sync status after two local commits"); - assert_eq!( - ahead, 2, - "After two local commits, should be 2 ahead, got {}", - ahead - ); - assert_eq!( - behind, 0, - "After two local commits, should be 0 behind, got {}", - behind - ); -} diff --git a/src-tauri/tests/core_repo_test.rs b/src-tauri/tests/core_repo_test.rs index ea2259c9..7409a36c 100644 --- a/src-tauri/tests/core_repo_test.rs +++ b/src-tauri/tests/core_repo_test.rs @@ -228,62 +228,90 @@ fn test_repo_status_with_remote_no_fetch_error() { } #[test] -fn test_repo_status_with_remote_in_sync() { - let repo = TestRepo::with_remote().expect("Failed to create test repo with remote"); - - let status = repo_status(&repo.repo_path).expect("repo_status should succeed"); - - // After TestRepo::with_remote(), main is pushed — should be in sync - assert_eq!( - status.remote_sync, - RemoteSyncStatus::InSync, - "repo should be in sync after push, got: {:?}", - status.remote_sync - ); -} - -#[test] -fn test_repo_status_with_remote_ahead() { - let repo = TestRepo::with_remote().expect("Failed to create test repo with remote"); - - // Make a local commit that hasn't been pushed - repo.commit_file( - "local_only.txt", - "local content", - "Local commit not yet pushed", - ) - .expect("Failed to commit file"); - - let status = repo_status(&repo.repo_path).expect("repo_status should succeed"); - - match status.remote_sync { - RemoteSyncStatus::Ahead { count } => { - assert!(count > 0, "should be at least 1 commit ahead"); - } - other => panic!("expected Ahead, got {:?}", other), +fn test_repo_status_remote_sync_variants() { + // Table-driven: each case sets up a repo with a remote, applies a local/remote + // action, and checks the resulting RemoteSyncStatus variant. These used to be + // three near-identical tests differing only in the setup action and expected + // variant. + enum Expected { + InSync, + Ahead, + Behind, } -} - -#[test] -fn test_repo_status_with_remote_behind() { - let repo = TestRepo::with_remote().expect("Failed to create test repo with remote"); - - // Push a commit from the "remote" side only - repo.remote_commit_file( - "remote_only.txt", - "remote content", - "Remote commit not yet fetched", - ) - .expect("Failed to create remote commit"); - // repo_status includes fetch, so it will pull the new remote commit info - let status = repo_status(&repo.repo_path).expect("repo_status should succeed"); + struct Case { + name: &'static str, + // Returns the repo, after applying whatever local/remote action this case needs. + setup: fn() -> TestRepo, + expected: Expected, + } - match status.remote_sync { - RemoteSyncStatus::Behind { count } => { - assert!(count > 0, "should be at least 1 commit behind"); + let cases = vec![ + Case { + name: "in sync after push (no further action needed)", + setup: || TestRepo::with_remote().expect("Failed to create test repo with remote"), + expected: Expected::InSync, + }, + Case { + name: "ahead after an unpushed local commit", + setup: || { + let repo = + TestRepo::with_remote().expect("Failed to create test repo with remote"); + repo.commit_file( + "local_only.txt", + "local content", + "Local commit not yet pushed", + ) + .expect("Failed to commit file"); + repo + }, + expected: Expected::Ahead, + }, + Case { + name: "behind after a remote-only commit", + setup: || { + let repo = + TestRepo::with_remote().expect("Failed to create test repo with remote"); + repo.remote_commit_file( + "remote_only.txt", + "remote content", + "Remote commit not yet fetched", + ) + .expect("Failed to create remote commit"); + repo + }, + expected: Expected::Behind, + }, + ]; + + for case in cases { + let repo = (case.setup)(); + + // repo_status includes fetch, so behind-remote cases observe the new commits. + let status = repo_status(&repo.repo_path) + .unwrap_or_else(|error| panic!("[{}] repo_status should succeed: {:?}", case.name, error)); + + match case.expected { + Expected::InSync => assert_eq!( + status.remote_sync, + RemoteSyncStatus::InSync, + "[{}] repo should be in sync, got: {:?}", + case.name, + status.remote_sync + ), + Expected::Ahead => match status.remote_sync { + RemoteSyncStatus::Ahead { count } => { + assert!(count > 0, "[{}] should be at least 1 commit ahead", case.name); + } + other => panic!("[{}] expected Ahead, got {:?}", case.name, other), + }, + Expected::Behind => match status.remote_sync { + RemoteSyncStatus::Behind { count } => { + assert!(count > 0, "[{}] should be at least 1 commit behind", case.name); + } + other => panic!("[{}] expected Behind, got {:?}", case.name, other), + }, } - other => panic!("expected Behind, got {:?}", other), } } diff --git a/src-tauri/tests/core_workspaces_test.rs b/src-tauri/tests/core_workspaces_test.rs index 3a503407..029e7751 100644 --- a/src-tauri/tests/core_workspaces_test.rs +++ b/src-tauri/tests/core_workspaces_test.rs @@ -1174,180 +1174,143 @@ fn test_empty_commits_excluded_from_commits_ahead() { } #[test] -fn test_merge_abandons_empty_commits() { - let repo = TestRepo::new().expect("Failed to create test repo"); - - let workspace: Workspace = treq_lib::core::create_workspace( - &repo.repo_path, - "feat/merge-empty", - Some("merge empty test".to_string()), - None, - None, - None, - None, - ) - .expect("Failed to create workspace"); - - let workspace_path = repo.workspaces_dir().join(&workspace.workspace_path); - let workspace_path_str = workspace_path.to_str().unwrap(); - - // Create a real commit - TestRepo::write_workspace_file(workspace_path_str, "feature.txt", "feature content") - .expect("Failed to write file"); - treq_lib::core::commit_workspace(&repo.repo_path, workspace.id, "Add feature") - .expect("Failed to commit"); - - // Create empty commits - Command::new("jj") - .current_dir(workspace_path_str) - .args(["new"]) - .output() - .expect("Failed to run jj new"); - - let log = treq_lib::core::list_commits(&repo.repo_path, Some(workspace.id), false, None, None) - .expect("list_commits failed"); - assert_eq!( - log.commits.len(), - 1, - "list_commits should not include empty commits, got: {:?}", - log.commits - .iter() - .map(|c| c.description.as_str()) - .collect::>() - ); - assert!( - log.commits[0].description.contains("Add feature"), - "Expected the real commit only, got: {:?}", - log.commits[0].description - ); - - // Merge should succeed despite empty commits - treq_lib::core::merge_workspace( - &repo.repo_path, - workspace.id, - "Merge feat/merge-empty", - MergeCommit::Merge, - ) - .expect("Failed to merge workspace with empty commits"); - - // Verify the file is in the main repo - assert!( - Path::new(&repo.repo_path).join("feature.txt").exists(), - "Feature file should exist in main repo after merge" - ); - - // Verify workspace is cleaned up - assert!( - !workspace_path.exists(), - "Workspace directory should be deleted after merge" - ); -} - -#[test] -fn test_squash_merge_with_empty_commits() { - let repo = TestRepo::new().expect("Failed to create test repo"); - - let workspace: Workspace = treq_lib::core::create_workspace( - &repo.repo_path, - "feat/squash-empty", - Some("squash empty test".to_string()), - None, - None, - None, - None, - ) - .expect("Failed to create workspace"); - - let workspace_path = repo.workspaces_dir().join(&workspace.workspace_path); - let workspace_path_str = workspace_path.to_str().unwrap(); - - // Create a real commit - TestRepo::write_workspace_file(workspace_path_str, "squash.txt", "squash content") - .expect("Failed to write file"); - treq_lib::core::commit_workspace(&repo.repo_path, workspace.id, "Add squash file") - .expect("Failed to commit"); - - // Create empty commits - Command::new("jj") - .current_dir(workspace_path_str) - .args(["new"]) - .output() - .expect("Failed to run jj new"); - Command::new("jj") - .current_dir(workspace_path_str) - .args(["new"]) - .output() - .expect("Failed to run jj new"); - - // Squash merge should succeed - treq_lib::core::merge_workspace( - &repo.repo_path, - workspace.id, - "Squash feat/squash-empty", - MergeCommit::SquashAndMerge, - ) - .expect("Failed to squash merge workspace with empty commits"); - - assert!( - Path::new(&repo.repo_path).join("squash.txt").exists(), - "Squash file should exist in main repo after merge" - ); - - assert!( - !workspace_path.exists(), - "Workspace directory should be deleted after squash merge" - ); -} - -#[test] -fn test_rebase_merge_with_empty_commits() { - let repo = TestRepo::new().expect("Failed to create test repo"); - - let workspace: Workspace = treq_lib::core::create_workspace( - &repo.repo_path, - "feat/rebase-empty", - Some("rebase empty test".to_string()), - None, - None, - None, - None, - ) - .expect("Failed to create workspace"); - - let workspace_path = repo.workspaces_dir().join(&workspace.workspace_path); - let workspace_path_str = workspace_path.to_str().unwrap(); - - // Create a real commit - TestRepo::write_workspace_file(workspace_path_str, "rebase.txt", "rebase content") - .expect("Failed to write file"); - treq_lib::core::commit_workspace(&repo.repo_path, workspace.id, "Add rebase file") - .expect("Failed to commit"); - - // Create empty commits - Command::new("jj") - .current_dir(workspace_path_str) - .args(["new"]) - .output() - .expect("Failed to run jj new"); - - // Rebase merge should succeed - treq_lib::core::merge_workspace( - &repo.repo_path, - workspace.id, - "Rebase feat/rebase-empty", - MergeCommit::RebaseAndMerge, - ) - .expect("Failed to rebase merge workspace with empty commits"); - - assert!( - Path::new(&repo.repo_path).join("rebase.txt").exists(), - "Rebase file should exist in main repo after merge" - ); - - assert!( - !workspace_path.exists(), - "Workspace directory should be deleted after rebase merge" - ); +fn test_merge_workspace_with_empty_commits_cases() { + // Table-driven: each merge strategy (Merge, SquashAndMerge, RebaseAndMerge) + // should succeed despite trailing empty commits in the workspace, and clean up + // the workspace afterward. These used to be three near-identical tests + // differing only in branch/file names, empty commit count, and merge strategy. + struct Case { + name: &'static str, + branch: &'static str, + file_name: &'static str, + file_contents: &'static str, + commit_message: &'static str, + empty_commit_count: usize, + merge_message: &'static str, + strategy: MergeCommit, + // Only the original "merge" case additionally checked list_commits excludes + // empty commits; the other two cases didn't assert on this. + check_list_commits_excludes_empty: bool, + } + + let cases = vec![ + Case { + name: "plain merge abandons empty commits", + branch: "feat/merge-empty", + file_name: "feature.txt", + file_contents: "feature content", + commit_message: "Add feature", + empty_commit_count: 1, + merge_message: "Merge feat/merge-empty", + strategy: MergeCommit::Merge, + check_list_commits_excludes_empty: true, + }, + Case { + name: "squash merge with empty commits", + branch: "feat/squash-empty", + file_name: "squash.txt", + file_contents: "squash content", + commit_message: "Add squash file", + empty_commit_count: 2, + merge_message: "Squash feat/squash-empty", + strategy: MergeCommit::SquashAndMerge, + check_list_commits_excludes_empty: false, + }, + Case { + name: "rebase merge with empty commits", + branch: "feat/rebase-empty", + file_name: "rebase.txt", + file_contents: "rebase content", + commit_message: "Add rebase file", + empty_commit_count: 1, + merge_message: "Rebase feat/rebase-empty", + strategy: MergeCommit::RebaseAndMerge, + check_list_commits_excludes_empty: false, + }, + ]; + + for case in cases { + let repo = TestRepo::new().expect("Failed to create test repo"); + + let workspace: Workspace = treq_lib::core::create_workspace( + &repo.repo_path, + case.branch, + Some(format!("{} test", case.name)), + None, + None, + None, + None, + ) + .unwrap_or_else(|error| panic!("[{}] Failed to create workspace: {:?}", case.name, error)); + + let workspace_path = repo.workspaces_dir().join(&workspace.workspace_path); + let workspace_path_str = workspace_path.to_str().unwrap(); + + // Create a real commit + TestRepo::write_workspace_file(workspace_path_str, case.file_name, case.file_contents) + .unwrap_or_else(|error| panic!("[{}] Failed to write file: {:?}", case.name, error)); + treq_lib::core::commit_workspace(&repo.repo_path, workspace.id, case.commit_message) + .unwrap_or_else(|error| panic!("[{}] Failed to commit: {:?}", case.name, error)); + + // Create empty commits + for _ in 0..case.empty_commit_count { + Command::new("jj") + .current_dir(workspace_path_str) + .args(["new"]) + .output() + .unwrap_or_else(|error| panic!("[{}] Failed to run jj new: {:?}", case.name, error)); + } + + if case.check_list_commits_excludes_empty { + let log = + treq_lib::core::list_commits(&repo.repo_path, Some(workspace.id), false, None, None) + .unwrap_or_else(|error| panic!("[{}] list_commits failed: {:?}", case.name, error)); + assert_eq!( + log.commits.len(), + 1, + "[{}] list_commits should not include empty commits, got: {:?}", + case.name, + log.commits + .iter() + .map(|c| c.description.as_str()) + .collect::>() + ); + assert!( + log.commits[0].description.contains(case.commit_message), + "[{}] Expected the real commit only, got: {:?}", + case.name, + log.commits[0].description + ); + } + + // Merge should succeed despite empty commits + treq_lib::core::merge_workspace( + &repo.repo_path, + workspace.id, + case.merge_message, + case.strategy, + ) + .unwrap_or_else(|error| { + panic!( + "[{}] Failed to merge workspace with empty commits: {:?}", + case.name, error + ) + }); + + // Verify the file is in the main repo + assert!( + Path::new(&repo.repo_path).join(case.file_name).exists(), + "[{}] File should exist in main repo after merge", + case.name + ); + + // Verify workspace is cleaned up + assert!( + !workspace_path.exists(), + "[{}] Workspace directory should be deleted after merge", + case.name + ); + } } #[test] diff --git a/src-tauri/tests/pty_tests.rs b/src-tauri/tests/pty_tests.rs index f0ddb675..8aaf06f6 100644 --- a/src-tauri/tests/pty_tests.rs +++ b/src-tauri/tests/pty_tests.rs @@ -474,70 +474,81 @@ fn test_utf8_output() { } #[test] -fn test_strip_ansi_codes_plain() { - assert_eq!(strip_ansi_codes("hello world"), "hello world"); -} - -#[test] -fn test_strip_ansi_codes_csi() { - assert_eq!(strip_ansi_codes("\x1b[32mhello\x1b[0m"), "hello"); - assert_eq!(strip_ansi_codes("\x1b[1;31mred\x1b[0m"), "red"); -} - -#[test] -fn test_strip_ansi_codes_osc() { - assert_eq!(strip_ansi_codes("\x1b]0;title\x07text"), "text"); -} - -#[test] -fn test_strip_ansi_codes_charset() { - assert_eq!(strip_ansi_codes("\x1b(Bhello"), "hello"); -} - -#[test] -fn test_line_matches_auto_command_short_line() { - // Lines shorter than 20 chars should never match - assert!(!line_matches_auto_command( - "short", - "some long auto command that is definitely long enough" - )); -} - -#[test] -fn test_line_matches_auto_command_exact_substring() { - let auto_cmd = "claude --permission-mode acceptEdits --append-system-prompt 'some long prompt'"; - let line = "claude --permission-mode acceptEdits --append-system-prompt 'some long prompt'"; - assert!(line_matches_auto_command(line, auto_cmd)); -} - -#[test] -fn test_line_matches_auto_command_partial_overlap() { - let auto_cmd = "claude --permission-mode acceptEdits --append-system-prompt 'very long system prompt text here'"; - // A line that contains a 20+ char substring of the auto command - let line = "$ claude --permission-mode acceptEdits --append-system-prompt 'very long system prompt text here'"; - assert!(line_matches_auto_command(line, auto_cmd)); +fn test_strip_ansi_codes_cases() { + // Table-driven: each case is a (name, input, expected) triple. Used to be four + // near-identical single-assertion tests for different escape sequence kinds. + let cases: Vec<(&str, &str, &str)> = vec![ + ("plain text is untouched", "hello world", "hello world"), + ("CSI color codes are stripped", "\x1b[32mhello\x1b[0m", "hello"), + ("CSI codes with multiple params are stripped", "\x1b[1;31mred\x1b[0m", "red"), + ("OSC title-setting sequence is stripped", "\x1b]0;title\x07text", "text"), + ("charset selection sequence is stripped", "\x1b(Bhello", "hello"), + ]; + + for (name, input, expected) in cases { + assert_eq!(strip_ansi_codes(input), expected, "case: {name}"); + } } #[test] -fn test_line_matches_auto_command_no_match() { - let auto_cmd = "claude --permission-mode acceptEdits --append-system-prompt 'some prompt'"; - let line = "total 42\ndrwxr-xr-x 5 user staff 160 Jan 1 00:00 ."; - assert!(!line_matches_auto_command(line, auto_cmd)); -} +fn test_line_matches_auto_command_cases() { + // Table-driven: each case is (name, line, auto_cmd, expected_match). Used to be + // five near-identical tests exercising line_matches_auto_command's substring + // matching heuristic. + struct Case { + name: &'static str, + line: &'static str, + auto_cmd: &'static str, + expected: bool, + } -#[test] -fn test_line_matches_auto_command_normal_output() { - let auto_cmd = - "claude --permission-mode acceptEdits --append-system-prompt 'hello world this is a test'"; - // Normal CLI output should not match - assert!(!line_matches_auto_command( - "Hello! How can I help you today?", - auto_cmd - )); - assert!(!line_matches_auto_command( - "Processing your request...", - auto_cmd - )); + let cases = vec![ + Case { + name: "lines shorter than 20 chars never match", + line: "short", + auto_cmd: "some long auto command that is definitely long enough", + expected: false, + }, + Case { + name: "exact substring match", + line: "claude --permission-mode acceptEdits --append-system-prompt 'some long prompt'", + auto_cmd: "claude --permission-mode acceptEdits --append-system-prompt 'some long prompt'", + expected: true, + }, + Case { + name: "line contains a 20+ char substring of the auto command", + line: "$ claude --permission-mode acceptEdits --append-system-prompt 'very long system prompt text here'", + auto_cmd: "claude --permission-mode acceptEdits --append-system-prompt 'very long system prompt text here'", + expected: true, + }, + Case { + name: "unrelated long line does not match", + line: "total 42\ndrwxr-xr-x 5 user staff 160 Jan 1 00:00 .", + auto_cmd: "claude --permission-mode acceptEdits --append-system-prompt 'some prompt'", + expected: false, + }, + Case { + name: "normal CLI output does not match (greeting)", + line: "Hello! How can I help you today?", + auto_cmd: "claude --permission-mode acceptEdits --append-system-prompt 'hello world this is a test'", + expected: false, + }, + Case { + name: "normal CLI output does not match (progress message)", + line: "Processing your request...", + auto_cmd: "claude --permission-mode acceptEdits --append-system-prompt 'hello world this is a test'", + expected: false, + }, + ]; + + for case in cases { + assert_eq!( + line_matches_auto_command(case.line, case.auto_cmd), + case.expected, + "case: {}", + case.name + ); + } } #[test]