Files
ldr/tests/integration_tests.rs
T

811 lines
26 KiB
Rust

//! Integration tests for the LDR CLI application
//!
//! These tests verify the entire application works correctly by running actual CLI commands
//! with temporary data directories. Tests cover all functionality and edge cases according to POLS.
use std::env;
use std::fs;
use std::path::PathBuf;
use std::process::{Command, Stdio};
use tempfile::TempDir;
/// Test fixture that provides isolated temporary directories for each test
struct TestEnv {
_temp_dir: TempDir,
data_dir: PathBuf,
binary_path: PathBuf,
}
impl TestEnv {
fn new() -> Self {
let temp_dir = TempDir::new().expect("Failed to create temp directory");
let data_dir = temp_dir.path().join("ldr");
fs::create_dir_all(&data_dir).expect("Failed to create ldr data directory");
// Build the binary if it doesn't exist or is out of date
let binary_path = Self::ensure_binary_built();
Self {
_temp_dir: temp_dir,
data_dir,
binary_path,
}
}
fn ensure_binary_built() -> PathBuf {
let output = Command::new("cargo")
.args(&["build", "--bin", "ldr"])
.output()
.expect("Failed to build ldr binary");
if !output.status.success() {
panic!("Failed to build binary: {}", String::from_utf8_lossy(&output.stderr));
}
let mut binary_path = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
binary_path.push("target/debug/ldr");
binary_path
}
/// Run ldr command with arguments in the isolated environment
fn run_ldr(&self, args: &[&str]) -> CommandResult {
let output = Command::new(&self.binary_path)
.args(args)
.env("XDG_DATA_HOME", &self.data_dir)
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.output()
.expect("Failed to execute ldr command");
CommandResult {
status: output.status.code().unwrap_or(-1),
stdout: String::from_utf8_lossy(&output.stdout).to_string(),
stderr: String::from_utf8_lossy(&output.stderr).to_string(),
}
}
/// Get the path to the todos.md file
fn todos_path(&self) -> PathBuf {
self.data_dir.join("ldr/todos.md")
}
/// Get the path to the archive.md file
fn archive_path(&self) -> PathBuf {
self.data_dir.join("ldr/archive.md")
}
/// Read the contents of todos.md
fn read_todos(&self) -> String {
fs::read_to_string(self.todos_path()).unwrap_or_default()
}
/// Read the contents of archive.md
fn read_archive(&self) -> String {
fs::read_to_string(self.archive_path()).unwrap_or_default()
}
}
#[derive(Debug)]
struct CommandResult {
status: i32,
stdout: String,
stderr: String,
}
impl CommandResult {
fn assert_success(&self) {
if self.status != 0 {
panic!("Command failed with status {}: stderr: {}", self.status, self.stderr);
}
}
fn assert_failure(&self) {
// For LDR, failure is indicated by error messages in stdout, not exit code
if !self.stdout.contains("Invalid") && !self.stderr.contains("Error") && !self.stdout.is_empty() {
panic!("Command unexpectedly succeeded: stdout: {}", self.stdout);
}
}
}
#[cfg(test)]
mod basic_operations {
use super::*;
#[test]
fn test_add_and_list_basic() {
let env = TestEnv::new();
// Add first item
let result = env.run_ldr(&["add", "First task"]);
result.assert_success();
assert!(result.stdout.contains("✓ Added to Default: First task"));
// Add second item (should go to top)
let result = env.run_ldr(&["add", "Second task"]);
result.assert_success();
// List items
let result = env.run_ldr(&["ls"]);
result.assert_success();
// Check that second task appears first (POLS: newest items at top)
// Note: Output includes ANSI color codes, so we check for task text
assert!(result.stdout.contains("1. Second task"));
assert!(result.stdout.contains("2. First task"));
}
#[test]
fn test_empty_list() {
let env = TestEnv::new();
let result = env.run_ldr(&["ls"]);
result.assert_success();
assert!(result.stdout.contains("No notes yet."));
}
#[test]
fn test_list_with_limits() {
let env = TestEnv::new();
// Add 10 items
for i in 1..=10 {
let result = env.run_ldr(&["add", &format!("Task {}", i)]);
result.assert_success();
}
// Test default limit (5)
let result = env.run_ldr(&["ls"]);
result.assert_success();
let line_count = result.stdout.lines().count();
// Should show 5 items plus "... and X more items" line
assert!(line_count >= 5);
assert!(result.stdout.contains("and 5 more items"));
// Test custom limit
let result = env.run_ldr(&["ls", "-n", "3"]);
result.assert_success();
let lines: Vec<&str> = result.stdout.lines().collect();
assert!(lines.len() >= 3);
// Test show all
let result = env.run_ldr(&["ls", "--all"]);
result.assert_success();
assert!(!result.stdout.contains("more items"));
}
}
#[cfg(test)]
mod subtask_operations {
use super::*;
#[test]
fn test_add_subtask() {
let env = TestEnv::new();
// Add main task
let result = env.run_ldr(&["add", "Main task"]);
result.assert_success();
// Add subtask
let result = env.run_ldr(&["add", "Subtask A", "--under", "1"]);
result.assert_success();
assert!(result.stdout.contains("✓ Added subtask to task 1: Subtask A"));
// Add another subtask
let result = env.run_ldr(&["add", "Subtask B", "--under", "1"]);
result.assert_success();
// List and verify structure
let result = env.run_ldr(&["ls"]);
result.assert_success();
// Verify main task and subtasks appear in order added
assert!(result.stdout.contains("1. Main task"));
assert!(result.stdout.contains("a. Subtask A")); // First subtask
assert!(result.stdout.contains("b. Subtask B")); // Second subtask
}
#[test]
fn test_add_subtask_invalid_parent() {
let env = TestEnv::new();
// Try to add subtask to non-existent task
let result = env.run_ldr(&["add", "Orphan subtask", "--under", "1"]);
result.assert_failure();
assert!(result.stderr.contains("Invalid task number: 1"));
}
#[test]
fn test_subtask_numbering() {
let env = TestEnv::new();
// Add main task
env.run_ldr(&["add", "Task with many subtasks"]);
// Add multiple subtasks
for i in 1..=5 {
let result = env.run_ldr(&["add", &format!("Subtask {}", i), "--under", "1"]);
result.assert_success();
}
let result = env.run_ldr(&["ls", "--all"]);
result.assert_success();
// Verify letter sequence (in order added: a, b, c, d, e)
assert!(result.stdout.contains("a. Subtask 1"));
assert!(result.stdout.contains("b. Subtask 2"));
assert!(result.stdout.contains("c. Subtask 3"));
assert!(result.stdout.contains("d. Subtask 4"));
assert!(result.stdout.contains("e. Subtask 5"));
}
}
#[cfg(test)]
mod prioritization_tests {
use super::*;
#[test]
fn test_prioritize_basic() {
let env = TestEnv::new();
// Add tasks
env.run_ldr(&["add", "Task A"]);
env.run_ldr(&["add", "Task B"]);
env.run_ldr(&["add", "Task C"]);
// Prioritize task 3 (Task A)
let result = env.run_ldr(&["up", "3"]);
result.assert_success();
assert!(result.stdout.contains("✓ Prioritized 1 task(s)"));
// Verify new order
let result = env.run_ldr(&["ls"]);
result.assert_success();
let lines: Vec<&str> = result.stdout.lines().collect();
assert!(lines[0].contains("1. Task A")); // Moved to top
assert!(lines[1].contains("2. Task C"));
assert!(lines[2].contains("3. Task B"));
}
#[test]
fn test_prioritize_multiple() {
let env = TestEnv::new();
// Add tasks
env.run_ldr(&["add", "Task A"]);
env.run_ldr(&["add", "Task B"]);
env.run_ldr(&["add", "Task C"]);
env.run_ldr(&["add", "Task D"]);
// Prioritize tasks 4, 2 (in that order)
let result = env.run_ldr(&["up", "4", "2"]);
result.assert_success();
// Verify command-line order preserved (POLS)
let result = env.run_ldr(&["ls"]);
result.assert_success();
let lines: Vec<&str> = result.stdout.lines().collect();
assert!(lines[0].contains("1. Task A")); // 4th item moved to top
assert!(lines[1].contains("2. Task C")); // 2nd item moved to second
assert!(lines[2].contains("3. Task D")); // Remaining items
assert!(lines[3].contains("4. Task B"));
}
#[test]
fn test_prioritize_subtask_moves_parent() {
let env = TestEnv::new();
// Add tasks with subtasks
env.run_ldr(&["add", "Task A"]);
env.run_ldr(&["add", "Subtask A1", "--under", "1"]);
env.run_ldr(&["add", "Task B"]);
env.run_ldr(&["add", "Subtask B1", "--under", "2"]);
// Prioritize subtask - should move entire parent task
let result = env.run_ldr(&["up", "2a"]); // Task A is now at position 2
result.assert_success();
// Verify parent task moved (POLS: subtask reference affects parent)
let result = env.run_ldr(&["ls"]);
result.assert_success();
assert!(result.stdout.contains("1. Task A")); // Parent moved to top
assert!(result.stdout.contains("a. Subtask A1"));
}
#[test]
fn test_prioritize_invalid_reference() {
let env = TestEnv::new();
env.run_ldr(&["add", "Task A"]);
let result = env.run_ldr(&["up", "5"]);
result.assert_failure();
let result = env.run_ldr(&["up", "1z"]); // Invalid subtask
result.assert_failure();
}
}
#[cfg(test)]
mod archiving_tests {
use super::*;
#[test]
fn test_archive_single_task() {
let env = TestEnv::new();
env.run_ldr(&["add", "Task to complete"]);
let result = env.run_ldr(&["do", "1"]);
result.assert_success();
assert!(result.stdout.contains("✓ Archived 1 item(s)"));
// Verify task removed from todos
let todos = env.read_todos();
assert!(!todos.contains("Task to complete"));
// Verify task in archive
let archive = env.read_archive();
assert!(archive.contains("Task to complete"));
assert!(archive.contains(&chrono::Local::now().format("%Y-%m-%d").to_string()));
}
#[test]
fn test_archive_subtask() {
let env = TestEnv::new();
env.run_ldr(&["add", "Main task"]);
env.run_ldr(&["add", "Subtask A", "--under", "1"]);
env.run_ldr(&["add", "Subtask B", "--under", "1"]);
// Archive one subtask
let result = env.run_ldr(&["do", "1a"]);
result.assert_success();
// Main task should still exist with remaining subtask
let result = env.run_ldr(&["ls"]);
result.assert_success();
assert!(result.stdout.contains("Main task"));
assert!(result.stdout.contains("Subtask B"));
assert!(!result.stdout.contains("Subtask A"));
// Archive should contain the subtask
let archive = env.read_archive();
assert!(archive.contains("Subtask A"));
}
#[test]
fn test_auto_complete_parent_when_all_subtasks_done() {
let env = TestEnv::new();
env.run_ldr(&["add", "Main task"]);
env.run_ldr(&["add", "Subtask A", "--under", "1"]);
env.run_ldr(&["add", "Subtask B", "--under", "1"]);
// Archive all subtasks
let result = env.run_ldr(&["do", "1a", "1b"]);
result.assert_success();
// Main task should be auto-completed (POLS: completing all subtasks completes parent)
let result = env.run_ldr(&["ls"]);
result.assert_success();
assert!(!result.stdout.contains("Main task"));
// Archive should contain subtasks AND parent task
let archive = env.read_archive();
assert!(archive.contains("Subtask A"));
assert!(archive.contains("Subtask B"));
assert!(archive.contains("Main task"));
}
#[test]
fn test_archive_whole_task_with_subtasks() {
let env = TestEnv::new();
env.run_ldr(&["add", "Complete project"]);
env.run_ldr(&["add", "Write code", "--under", "1"]);
env.run_ldr(&["add", "Write tests", "--under", "1"]);
// Archive entire task (should include all subtasks)
let result = env.run_ldr(&["do", "1"]);
result.assert_success();
// Nothing should remain in todos
let result = env.run_ldr(&["ls"]);
result.assert_success();
assert!(result.stdout.contains("No notes yet"));
// Archive should contain complete task structure
let archive = env.read_archive();
assert!(archive.contains("Complete project"));
assert!(archive.contains("Write code"));
assert!(archive.contains("Write tests"));
}
}
#[cfg(test)]
mod removal_tests {
use super::*;
#[test]
fn test_remove_vs_archive() {
let env = TestEnv::new();
env.run_ldr(&["add", "Task to remove"]);
env.run_ldr(&["add", "Task to archive"]);
// Remove one task
let result = env.run_ldr(&["rm", "2"]);
result.assert_success();
// Archive another task
let result = env.run_ldr(&["do", "1"]);
result.assert_success();
// Only archived task should be in archive
let archive = env.read_archive();
assert!(archive.contains("Task to archive"));
assert!(!archive.contains("Task to remove"));
// Both should be gone from todos
let result = env.run_ldr(&["ls"]);
result.assert_success();
assert!(result.stdout.contains("No notes yet"));
}
#[test]
fn test_remove_subtask() {
let env = TestEnv::new();
env.run_ldr(&["add", "Main task"]);
env.run_ldr(&["add", "Keep this", "--under", "1"]);
env.run_ldr(&["add", "Remove this", "--under", "1"]);
// Remove second subtask (1b)
let result = env.run_ldr(&["rm", "1b"]);
result.assert_success();
// Main task should remain with first subtask only
let result = env.run_ldr(&["ls"]);
result.assert_success();
assert!(result.stdout.contains("Main task"));
assert!(result.stdout.contains("Keep this"));
assert!(!result.stdout.contains("Remove this"));
// Archive should be empty
let archive = env.read_archive();
assert!(!archive.contains("Remove this"));
}
}
#[cfg(test)]
mod filtering_tests {
use super::*;
#[test]
fn test_filtering_basic() {
let env = TestEnv::new();
env.run_ldr(&["add", "read: Book about Rust"]);
env.run_ldr(&["add", "write: Article about Go"]);
env.run_ldr(&["add", "read: Documentation"]);
env.run_ldr(&["add", "@work: Review PR"]);
// Filter by "read:"
let result = env.run_ldr(&["ls", "read:"]);
result.assert_success();
assert!(result.stdout.contains("read: Book about Rust"));
assert!(result.stdout.contains("read: Documentation"));
assert!(!result.stdout.contains("write: Article"));
assert!(!result.stdout.contains("@work: Review"));
// Filter by "@work"
let result = env.run_ldr(&["ls", "@work"]);
result.assert_success();
assert!(result.stdout.contains("@work: Review PR"));
assert!(!result.stdout.contains("read:"));
}
#[test]
fn test_filtering_case_insensitive() {
let env = TestEnv::new();
env.run_ldr(&["add", "READ: Book"]);
env.run_ldr(&["add", "read: Article"]);
env.run_ldr(&["add", "ReAd: Mixed"]);
let result = env.run_ldr(&["ls", "read:"]);
result.assert_success();
assert!(result.stdout.contains("READ: Book"));
assert!(result.stdout.contains("read: Article"));
assert!(result.stdout.contains("ReAd: Mixed"));
}
#[test]
fn test_filtering_with_limits() {
let env = TestEnv::new();
// Add many matching items
for i in 1..=10 {
env.run_ldr(&["add", &format!("read: Book {}", i)]);
}
env.run_ldr(&["add", "write: Article"]);
// Test filtering respects limits
let result = env.run_ldr(&["ls", "-n", "3", "read:"]);
result.assert_success();
let matching_lines: Vec<&str> = result.stdout.lines()
.filter(|line| line.contains("read:"))
.collect();
assert!(matching_lines.len() <= 3);
// Test filtering with --all
let result = env.run_ldr(&["ls", "--all", "read:"]);
result.assert_success();
let matching_lines: Vec<&str> = result.stdout.lines()
.filter(|line| line.contains("read:"))
.collect();
assert_eq!(matching_lines.len(), 10);
}
#[test]
fn test_filtering_no_matches() {
let env = TestEnv::new();
env.run_ldr(&["add", "Task A"]);
env.run_ldr(&["add", "Task B"]);
let result = env.run_ldr(&["ls", "nonexistent"]);
result.assert_success();
assert!(result.stdout.contains("No items found matching filter: \"nonexistent\""));
}
}
#[cfg(test)]
mod error_handling {
use super::*;
#[test]
fn test_invalid_task_references() {
let env = TestEnv::new();
env.run_ldr(&["add", "Task A"]);
// Test various invalid references
let invalid_refs = vec!["0", "99", "1z", "a1", "1ab", "1-2", ""];
for invalid_ref in invalid_refs {
if !invalid_ref.is_empty() {
let result = env.run_ldr(&["up", invalid_ref]);
result.assert_failure();
}
}
}
#[test]
fn test_operations_on_empty_file() {
let env = TestEnv::new();
// Try operations on empty file
let result = env.run_ldr(&["up", "1"]);
result.assert_success(); // Should handle gracefully
assert!(result.stdout.contains("No notes found"));
let result = env.run_ldr(&["do", "1"]);
result.assert_success();
assert!(result.stdout.contains("No notes found"));
let result = env.run_ldr(&["rm", "1"]);
result.assert_success();
}
#[test]
fn test_subtask_references_without_parent() {
let env = TestEnv::new();
env.run_ldr(&["add", "Task without subtasks"]);
// Try to reference non-existent subtask
let result = env.run_ldr(&["up", "1a"]);
result.assert_failure();
let result = env.run_ldr(&["do", "1a"]);
result.assert_failure();
let result = env.run_ldr(&["rm", "1a"]);
result.assert_failure();
}
}
#[cfg(test)]
mod edit_functionality {
use super::*;
#[test]
fn test_edit_creates_file_if_not_exists() {
let env = TestEnv::new();
// Set EDITOR to a command that just touches the file and exits
let result = Command::new(&env.binary_path)
.args(&["edit"])
.env("XDG_DATA_HOME", &env.data_dir)
.env("EDITOR", "touch") // Will just touch the file
.output()
.expect("Failed to execute edit command");
// Should succeed (touch command succeeds)
assert_eq!(result.status.code().unwrap_or(-1), 0);
// File should exist with proper structure
let todos = env.read_todos();
assert!(todos.contains("# TODOs"));
assert!(todos.contains("## Default"));
}
#[test]
fn test_edit_aliases() {
let env = TestEnv::new();
// Test all aliases work by using echo to verify they're called
let aliases = vec!["edit", "e", "scan", "s", "review", "r"];
for alias in aliases {
let result = Command::new(&env.binary_path)
.args(&[alias])
.env("XDG_DATA_HOME", &env.data_dir)
.env("EDITOR", "/bin/echo")
.output()
.expect(&format!("Failed to execute {} command", alias));
// Should succeed and echo the file path
assert_eq!(result.status.code().unwrap_or(-1), 0);
let stdout = String::from_utf8_lossy(&result.stdout);
assert!(stdout.contains("todos.md"), "Alias '{}' didn't work: {}", alias, stdout);
}
}
}
#[cfg(test)]
mod migration_tests {
use super::*;
#[test]
fn test_migration_from_plain_text() {
let env = TestEnv::new();
// Create old-style plain text files
let old_note_path = env.data_dir.join("ldr/note.txt");
let old_archive_path = env.data_dir.join("ldr/archive.txt");
fs::create_dir_all(old_note_path.parent().unwrap()).unwrap();
// Write old-style content
fs::write(&old_note_path, "Task A\nTask B with details\nTask C\n").unwrap();
fs::write(&old_archive_path, "Completed task 1\nCompleted task 2\n").unwrap();
// Run any command to trigger migration
let result = env.run_ldr(&["ls"]);
result.assert_success();
// Check that markdown files were created
assert!(env.todos_path().exists());
assert!(env.archive_path().exists());
// Verify content was migrated correctly
let todos = env.read_todos();
assert!(todos.contains("# TODOs"));
assert!(todos.contains("## Default"));
assert!(todos.contains("- Task A"));
assert!(todos.contains("- Task B with details"));
assert!(todos.contains("- Task C"));
let archive = env.read_archive();
assert!(archive.contains("# Archive"));
assert!(archive.contains("- Completed task 1"));
assert!(archive.contains("- Completed task 2"));
// Verify old files were backed up (not deleted)
assert!(old_note_path.with_extension("txt.bak").exists());
assert!(old_archive_path.with_extension("txt.bak").exists());
}
#[test]
fn test_no_migration_when_markdown_exists() {
let env = TestEnv::new();
// Create markdown file first
fs::create_dir_all(env.todos_path().parent().unwrap()).unwrap();
fs::write(&env.todos_path(), "# TODOs\n\n## Default\n- Existing task\n").unwrap();
// Create old-style file
let old_note_path = env.data_dir.join("ldr/note.txt");
fs::write(&old_note_path, "Old task\n").unwrap();
// Run command
let result = env.run_ldr(&["ls"]);
result.assert_success();
// Verify markdown file unchanged (no migration happened)
let todos = env.read_todos();
assert!(todos.contains("Existing task"));
assert!(!todos.contains("Old task"));
}
}
#[cfg(test)]
mod pols_compliance {
use super::*;
#[test]
fn test_newest_items_at_top() {
let env = TestEnv::new();
env.run_ldr(&["add", "Old task"]);
std::thread::sleep(std::time::Duration::from_millis(10));
env.run_ldr(&["add", "New task"]);
let result = env.run_ldr(&["ls"]);
result.assert_success();
assert!(result.stdout.contains("1. New task"));
assert!(result.stdout.contains("2. Old task"));
}
#[test]
fn test_prioritization_order_predictable() {
let env = TestEnv::new();
env.run_ldr(&["add", "A"]);
env.run_ldr(&["add", "B"]);
env.run_ldr(&["add", "C"]);
env.run_ldr(&["add", "D"]);
// Test prioritization is predictable
env.run_ldr(&["up", "4", "2"]); // Move A (4th) and C (2nd) to top
let result = env.run_ldr(&["ls"]);
result.assert_success();
assert!(result.stdout.contains("1. A"));
assert!(result.stdout.contains("2. C"));
assert!(result.stdout.contains("3. D"));
assert!(result.stdout.contains("4. B"));
}
#[test]
fn test_subtask_operations_affect_parent_logically() {
let env = TestEnv::new();
env.run_ldr(&["add", "Project A"]);
env.run_ldr(&["add", "Task 1", "--under", "1"]);
env.run_ldr(&["add", "Project B"]);
// Prioritizing subtask should move parent project
env.run_ldr(&["up", "2a"]); // Project A is now at position 2
let result = env.run_ldr(&["ls"]);
result.assert_success();
// Project A should be at top (moved because of subtask reference)
assert!(result.stdout.contains("1. Project A"));
assert!(result.stdout.contains("a. Task 1"));
}
#[test]
fn test_completion_cascades_logically() {
let env = TestEnv::new();
env.run_ldr(&["add", "Big project"]);
env.run_ldr(&["add", "Only subtask", "--under", "1"]);
// Completing the only subtask should complete parent
env.run_ldr(&["do", "1a"]);
let result = env.run_ldr(&["ls"]);
result.assert_success();
assert!(result.stdout.contains("No notes yet"));
// Both should be in archive
let archive = env.read_archive();
assert!(archive.contains("Only subtask"));
assert!(archive.contains("Big project"));
}
}