|
|
//! Unit tests for all `nbd` modules.
|
|
|
//!
|
|
|
//! Each module's behaviour is tested in isolation here. File I/O tests use
|
|
|
//! temporary directories provided by the `tempfile` crate so they leave no
|
|
|
//! state behind.
|
|
|
|
|
|
// ── ticket module ────────────────────────────────────────────────────────────
|
|
|
|
|
|
/// Tests for [`crate::ticket`].
|
|
|
mod ticket {
|
|
|
use crate::ticket::{generate_id, validate_priority, Status, Ticket, TicketType};
|
|
|
|
|
|
/// `Ticket::new` produces a ticket with the expected id, title, and defaults.
|
|
|
#[test]
|
|
|
fn new_sets_defaults() {
|
|
|
let t = Ticket::new("a3f9c2".to_string(), "Fix login bug".to_string());
|
|
|
assert_eq!(t.id, "a3f9c2");
|
|
|
assert_eq!(t.title, "Fix login bug");
|
|
|
assert_eq!(t.body, "");
|
|
|
assert_eq!(t.priority, 5);
|
|
|
assert_eq!(t.status, Status::Todo);
|
|
|
assert!(t.dependencies.is_empty());
|
|
|
assert_eq!(t.ticket_type, TicketType::Task);
|
|
|
}
|
|
|
|
|
|
/// A `Ticket` serialises to JSON and deserialises back with all fields intact
|
|
|
/// except `id`, which is not included in JSON (it is injected from the filename
|
|
|
/// by the store layer).
|
|
|
#[test]
|
|
|
fn ticket_roundtrip() {
|
|
|
let original = Ticket {
|
|
|
id: "b7d41e".to_string(),
|
|
|
title: "Add rate limiting".to_string(),
|
|
|
body: "Limit to 100 req/s".to_string(),
|
|
|
priority: 8,
|
|
|
status: Status::InProgress,
|
|
|
dependencies: vec!["a3f9c2".to_string()],
|
|
|
ticket_type: TicketType::Feature,
|
|
|
};
|
|
|
|
|
|
let json = serde_json::to_string(&original).expect("serialisation failed");
|
|
|
|
|
|
// id is skipped — the JSON must not contain it.
|
|
|
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
|
|
|
assert!(
|
|
|
parsed.get("id").is_none(),
|
|
|
"serialised JSON must not contain 'id', got: {json}"
|
|
|
);
|
|
|
|
|
|
let restored: Ticket = serde_json::from_str(&json).expect("deserialisation failed");
|
|
|
|
|
|
// id is populated by the store from the filename, so after a raw JSON
|
|
|
// roundtrip it will be the default empty string.
|
|
|
assert_eq!(
|
|
|
restored.id, "",
|
|
|
"id should be empty after raw JSON roundtrip"
|
|
|
);
|
|
|
assert_eq!(restored.title, original.title);
|
|
|
assert_eq!(restored.body, original.body);
|
|
|
assert_eq!(restored.priority, original.priority);
|
|
|
assert_eq!(restored.status, original.status);
|
|
|
assert_eq!(restored.dependencies, original.dependencies);
|
|
|
assert_eq!(restored.ticket_type, original.ticket_type);
|
|
|
}
|
|
|
|
|
|
/// `Status` variants serialise to the expected lowercase snake_case strings.
|
|
|
#[test]
|
|
|
fn status_serialises_to_snake_case() {
|
|
|
assert_eq!(serde_json::to_string(&Status::Todo).unwrap(), "\"todo\"");
|
|
|
assert_eq!(
|
|
|
serde_json::to_string(&Status::InProgress).unwrap(),
|
|
|
"\"in_progress\""
|
|
|
);
|
|
|
assert_eq!(serde_json::to_string(&Status::Done).unwrap(), "\"done\"");
|
|
|
assert_eq!(
|
|
|
serde_json::to_string(&Status::Closed).unwrap(),
|
|
|
"\"closed\""
|
|
|
);
|
|
|
assert_eq!(
|
|
|
serde_json::to_string(&Status::Archived).unwrap(),
|
|
|
"\"archived\""
|
|
|
);
|
|
|
assert_eq!(
|
|
|
serde_json::to_string(&Status::Backlog).unwrap(),
|
|
|
"\"backlog\""
|
|
|
);
|
|
|
}
|
|
|
|
|
|
/// `Status` deserialises correctly from lowercase snake_case strings.
|
|
|
#[test]
|
|
|
fn status_deserialises_from_snake_case() {
|
|
|
assert_eq!(
|
|
|
serde_json::from_str::<Status>("\"todo\"").unwrap(),
|
|
|
Status::Todo
|
|
|
);
|
|
|
assert_eq!(
|
|
|
serde_json::from_str::<Status>("\"in_progress\"").unwrap(),
|
|
|
Status::InProgress
|
|
|
);
|
|
|
assert_eq!(
|
|
|
serde_json::from_str::<Status>("\"done\"").unwrap(),
|
|
|
Status::Done
|
|
|
);
|
|
|
assert_eq!(
|
|
|
serde_json::from_str::<Status>("\"closed\"").unwrap(),
|
|
|
Status::Closed
|
|
|
);
|
|
|
assert_eq!(
|
|
|
serde_json::from_str::<Status>("\"archived\"").unwrap(),
|
|
|
Status::Archived
|
|
|
);
|
|
|
assert_eq!(
|
|
|
serde_json::from_str::<Status>("\"backlog\"").unwrap(),
|
|
|
Status::Backlog
|
|
|
);
|
|
|
}
|
|
|
|
|
|
/// `TicketType` variants serialise to the expected lowercase strings.
|
|
|
#[test]
|
|
|
fn ticket_type_serialises_to_lowercase() {
|
|
|
assert_eq!(
|
|
|
serde_json::to_string(&TicketType::Project).unwrap(),
|
|
|
"\"project\""
|
|
|
);
|
|
|
assert_eq!(
|
|
|
serde_json::to_string(&TicketType::Feature).unwrap(),
|
|
|
"\"feature\""
|
|
|
);
|
|
|
assert_eq!(
|
|
|
serde_json::to_string(&TicketType::Task).unwrap(),
|
|
|
"\"task\""
|
|
|
);
|
|
|
assert_eq!(serde_json::to_string(&TicketType::Bug).unwrap(), "\"bug\"");
|
|
|
}
|
|
|
|
|
|
/// `TicketType` deserialises correctly from lowercase strings.
|
|
|
#[test]
|
|
|
fn ticket_type_deserialises_from_lowercase() {
|
|
|
assert_eq!(
|
|
|
serde_json::from_str::<TicketType>("\"project\"").unwrap(),
|
|
|
TicketType::Project
|
|
|
);
|
|
|
assert_eq!(
|
|
|
serde_json::from_str::<TicketType>("\"bug\"").unwrap(),
|
|
|
TicketType::Bug
|
|
|
);
|
|
|
}
|
|
|
|
|
|
/// `validate_priority` accepts values 0–10 and rejects values above 10.
|
|
|
#[test]
|
|
|
fn priority_validation() {
|
|
|
assert!(validate_priority(0).is_ok());
|
|
|
assert!(validate_priority(5).is_ok());
|
|
|
assert!(validate_priority(10).is_ok());
|
|
|
assert!(validate_priority(11).is_err());
|
|
|
assert!(validate_priority(255).is_err());
|
|
|
}
|
|
|
|
|
|
/// `generate_id` returns a 6-character lowercase hex string.
|
|
|
#[test]
|
|
|
fn generated_id_is_six_hex_chars() {
|
|
|
let id = generate_id();
|
|
|
assert_eq!(id.len(), 6, "id length must be 6, got {id:?}");
|
|
|
assert!(
|
|
|
id.chars().all(|c| matches!(c, '0'..='9' | 'a'..='f')),
|
|
|
"id must be lowercase hex, got {id:?}"
|
|
|
);
|
|
|
}
|
|
|
|
|
|
/// Calling `generate_id` multiple times produces distinct values.
|
|
|
#[test]
|
|
|
fn generated_ids_are_unique() {
|
|
|
let ids: Vec<String> = (0..20).map(|_| generate_id()).collect();
|
|
|
let unique: std::collections::HashSet<&String> = ids.iter().collect();
|
|
|
// Allow at most 1 collision across 20 draws from a 16M space.
|
|
|
assert!(
|
|
|
unique.len() >= 19,
|
|
|
"too many collisions among generated IDs: {ids:?}"
|
|
|
);
|
|
|
}
|
|
|
}
|
|
|
|
|
|
// ── store module ──────────────────────────────────────────────────────────────
|
|
|
|
|
|
/// Tests for [`crate::store`].
|
|
|
mod store {
|
|
|
use std::path::Path;
|
|
|
|
|
|
use crate::store::{
|
|
|
ensure_tickets_dir, find_nbd_root_from, list_tickets, read_ticket, resolve_id, ticket_path,
|
|
|
tickets_dir, write_ticket, FileFormat,
|
|
|
};
|
|
|
use crate::ticket::{Status, Ticket, TicketType};
|
|
|
|
|
|
/// Helper: create a temporary directory with `.nbd/tickets/` already set up.
|
|
|
async fn setup_store() -> (tempfile::TempDir, std::path::PathBuf) {
|
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
|
let root = tmp.path().to_path_buf();
|
|
|
ensure_tickets_dir(&root).await.unwrap();
|
|
|
(tmp, root)
|
|
|
}
|
|
|
|
|
|
/// Writing a ticket and reading it back produces an identical value.
|
|
|
#[tokio::test]
|
|
|
async fn write_and_read_roundtrip() {
|
|
|
let (tmp, root) = setup_store().await;
|
|
|
let ticket = Ticket {
|
|
|
id: "a3f9c2".to_string(),
|
|
|
title: "Fix login bug".to_string(),
|
|
|
body: "Users cannot log in with email addresses containing +".to_string(),
|
|
|
priority: 8,
|
|
|
status: Status::InProgress,
|
|
|
dependencies: vec!["b7d41e".to_string()],
|
|
|
ticket_type: TicketType::Bug,
|
|
|
};
|
|
|
|
|
|
write_ticket(&root, &ticket, FileFormat::Json)
|
|
|
.await
|
|
|
.unwrap();
|
|
|
let restored = read_ticket(&root, "a3f9c2").await.unwrap();
|
|
|
|
|
|
assert_eq!(restored.id, ticket.id);
|
|
|
assert_eq!(restored.title, ticket.title);
|
|
|
assert_eq!(restored.body, ticket.body);
|
|
|
assert_eq!(restored.priority, ticket.priority);
|
|
|
assert_eq!(restored.status, ticket.status);
|
|
|
assert_eq!(restored.dependencies, ticket.dependencies);
|
|
|
assert_eq!(restored.ticket_type, ticket.ticket_type);
|
|
|
drop(tmp);
|
|
|
}
|
|
|
|
|
|
/// `write_ticket` does not include the `id` key in the JSON file.
|
|
|
#[tokio::test]
|
|
|
async fn write_ticket_omits_id_from_json() {
|
|
|
let (tmp, root) = setup_store().await;
|
|
|
let ticket = Ticket::new("c0ffee".to_string(), "Check JSON".to_string());
|
|
|
write_ticket(&root, &ticket, FileFormat::Json)
|
|
|
.await
|
|
|
.unwrap();
|
|
|
|
|
|
let path = ticket_path(&root, "c0ffee", FileFormat::Json);
|
|
|
let contents = tokio::fs::read_to_string(&path).await.unwrap();
|
|
|
let parsed: serde_json::Value = serde_json::from_str(&contents).unwrap();
|
|
|
assert!(
|
|
|
parsed.get("id").is_none(),
|
|
|
"written JSON must not contain the 'id' key, got: {contents}"
|
|
|
);
|
|
|
drop(tmp);
|
|
|
}
|
|
|
|
|
|
/// `read_ticket` injects the id from its parameter even when the file has no `id` field.
|
|
|
#[tokio::test]
|
|
|
async fn read_ticket_injects_id_from_parameter() {
|
|
|
let (tmp, root) = setup_store().await;
|
|
|
|
|
|
// Write a JSON file that has no "id" key (the new format).
|
|
|
let json = r#"{"title":"No id field","body":"","priority":5,"status":"todo","dependencies":[],"ticket_type":"task"}"#;
|
|
|
let dir = tickets_dir(&root);
|
|
|
tokio::fs::write(dir.join("abcdef.json"), json)
|
|
|
.await
|
|
|
.unwrap();
|
|
|
|
|
|
let ticket = read_ticket(&root, "abcdef").await.unwrap();
|
|
|
assert_eq!(ticket.id, "abcdef");
|
|
|
assert_eq!(ticket.title, "No id field");
|
|
|
drop(tmp);
|
|
|
}
|
|
|
|
|
|
/// `read_ticket` ignores any `id` key present in the JSON body (old format),
|
|
|
/// and instead uses the id passed as the parameter.
|
|
|
#[tokio::test]
|
|
|
async fn read_ticket_ignores_id_in_json_body() {
|
|
|
let (tmp, root) = setup_store().await;
|
|
|
|
|
|
// Simulate an old-format file that still has "id" in the JSON body.
|
|
|
let json = r#"{"id":"wrongid","title":"Old format","body":"","priority":5,"status":"todo","dependencies":[],"ticket_type":"task"}"#;
|
|
|
let dir = tickets_dir(&root);
|
|
|
tokio::fs::write(dir.join("aabbcc.json"), json)
|
|
|
.await
|
|
|
.unwrap();
|
|
|
|
|
|
let ticket = read_ticket(&root, "aabbcc").await.unwrap();
|
|
|
assert_eq!(
|
|
|
ticket.id, "aabbcc",
|
|
|
"id should come from the filename parameter, not the JSON body"
|
|
|
);
|
|
|
drop(tmp);
|
|
|
}
|
|
|
|
|
|
/// `list_tickets` injects the correct id from each filename stem.
|
|
|
#[tokio::test]
|
|
|
async fn list_tickets_injects_id_from_filename() {
|
|
|
let (tmp, root) = setup_store().await;
|
|
|
|
|
|
let mut t1 = Ticket::new("id1111".to_string(), "First".to_string());
|
|
|
t1.priority = 7;
|
|
|
let mut t2 = Ticket::new("id2222".to_string(), "Second".to_string());
|
|
|
t2.priority = 3;
|
|
|
write_ticket(&root, &t1, FileFormat::Json).await.unwrap();
|
|
|
write_ticket(&root, &t2, FileFormat::Json).await.unwrap();
|
|
|
|
|
|
let tickets = list_tickets(&root).await.unwrap();
|
|
|
assert_eq!(tickets.len(), 2);
|
|
|
// Sorted highest priority first.
|
|
|
assert_eq!(tickets[0].id, "id1111");
|
|
|
assert_eq!(tickets[1].id, "id2222");
|
|
|
drop(tmp);
|
|
|
}
|
|
|
|
|
|
/// Reading a non-existent ticket produces an error that mentions the ID.
|
|
|
#[tokio::test]
|
|
|
async fn read_missing_ticket_errors() {
|
|
|
let (tmp, root) = setup_store().await;
|
|
|
let result = read_ticket(&root, "ffffff").await;
|
|
|
assert!(result.is_err());
|
|
|
let msg = result.unwrap_err().to_string();
|
|
|
assert!(
|
|
|
msg.contains("ffffff"),
|
|
|
"error message should mention the ticket ID, got: {msg}"
|
|
|
);
|
|
|
drop(tmp);
|
|
|
}
|
|
|
|
|
|
/// `list_tickets` returns all written tickets sorted by priority descending.
|
|
|
#[tokio::test]
|
|
|
async fn list_returns_all_sorted_by_priority() {
|
|
|
let (tmp, root) = setup_store().await;
|
|
|
|
|
|
let mut low = Ticket::new("id0001".to_string(), "Low priority".to_string());
|
|
|
low.priority = 2;
|
|
|
let mut high = Ticket::new("id0002".to_string(), "High priority".to_string());
|
|
|
high.priority = 9;
|
|
|
let mut mid = Ticket::new("id0003".to_string(), "Mid priority".to_string());
|
|
|
mid.priority = 5;
|
|
|
|
|
|
write_ticket(&root, &low, FileFormat::Json).await.unwrap();
|
|
|
write_ticket(&root, &high, FileFormat::Json).await.unwrap();
|
|
|
write_ticket(&root, &mid, FileFormat::Json).await.unwrap();
|
|
|
|
|
|
let tickets = list_tickets(&root).await.unwrap();
|
|
|
assert_eq!(tickets.len(), 3);
|
|
|
assert_eq!(
|
|
|
tickets[0].priority, 9,
|
|
|
"first ticket should have highest priority"
|
|
|
);
|
|
|
assert_eq!(tickets[1].priority, 5);
|
|
|
assert_eq!(
|
|
|
tickets[2].priority, 2,
|
|
|
"last ticket should have lowest priority"
|
|
|
);
|
|
|
drop(tmp);
|
|
|
}
|
|
|
|
|
|
/// `resolve_id` returns the full ID when given an exact 6-char match.
|
|
|
#[tokio::test]
|
|
|
async fn resolve_id_exact_match() {
|
|
|
let (tmp, root) = setup_store().await;
|
|
|
let ticket = Ticket::new("a3f9c2".to_string(), "Exact".to_string());
|
|
|
write_ticket(&root, &ticket, FileFormat::Json)
|
|
|
.await
|
|
|
.unwrap();
|
|
|
|
|
|
let resolved = resolve_id(&root, "a3f9c2").await.unwrap();
|
|
|
assert_eq!(resolved, "a3f9c2");
|
|
|
drop(tmp);
|
|
|
}
|
|
|
|
|
|
/// `resolve_id` resolves a unique 3-char prefix to the full ID.
|
|
|
#[tokio::test]
|
|
|
async fn resolve_id_prefix_match() {
|
|
|
let (tmp, root) = setup_store().await;
|
|
|
let ticket = Ticket::new("a3f9c2".to_string(), "Prefix".to_string());
|
|
|
write_ticket(&root, &ticket, FileFormat::Json)
|
|
|
.await
|
|
|
.unwrap();
|
|
|
|
|
|
let resolved = resolve_id(&root, "a3f").await.unwrap();
|
|
|
assert_eq!(resolved, "a3f9c2");
|
|
|
drop(tmp);
|
|
|
}
|
|
|
|
|
|
/// `resolve_id` returns an error for an unknown prefix.
|
|
|
#[tokio::test]
|
|
|
async fn resolve_id_not_found() {
|
|
|
let (tmp, root) = setup_store().await;
|
|
|
let result = resolve_id(&root, "zzz").await;
|
|
|
assert!(result.is_err());
|
|
|
let msg = result.unwrap_err().to_string();
|
|
|
assert!(
|
|
|
msg.contains("zzz"),
|
|
|
"error should mention the prefix, got: {msg}"
|
|
|
);
|
|
|
drop(tmp);
|
|
|
}
|
|
|
|
|
|
/// `resolve_id` returns an error listing all matches for an ambiguous prefix.
|
|
|
#[tokio::test]
|
|
|
async fn resolve_id_ambiguous_prefix() {
|
|
|
let (tmp, root) = setup_store().await;
|
|
|
let t1 = Ticket::new("aabbcc".to_string(), "First".to_string());
|
|
|
let t2 = Ticket::new("aaddee".to_string(), "Second".to_string());
|
|
|
write_ticket(&root, &t1, FileFormat::Json).await.unwrap();
|
|
|
write_ticket(&root, &t2, FileFormat::Json).await.unwrap();
|
|
|
|
|
|
let result = resolve_id(&root, "aa").await;
|
|
|
assert!(result.is_err());
|
|
|
let msg = result.unwrap_err().to_string();
|
|
|
assert!(
|
|
|
msg.contains("ambiguous"),
|
|
|
"error should say ambiguous, got: {msg}"
|
|
|
);
|
|
|
assert!(
|
|
|
msg.contains("aabbcc"),
|
|
|
"error should list first match, got: {msg}"
|
|
|
);
|
|
|
assert!(
|
|
|
msg.contains("aaddee"),
|
|
|
"error should list second match, got: {msg}"
|
|
|
);
|
|
|
drop(tmp);
|
|
|
}
|
|
|
|
|
|
/// `list_tickets` returns an empty vec when the tickets directory is absent.
|
|
|
#[tokio::test]
|
|
|
async fn list_empty_when_no_tickets_dir() {
|
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
|
// Create `.nbd/` but not `.nbd/tickets/`.
|
|
|
std::fs::create_dir(tmp.path().join(".nbd")).unwrap();
|
|
|
let root = tmp.path().to_path_buf();
|
|
|
|
|
|
let tickets = list_tickets(&root).await.unwrap();
|
|
|
assert!(tickets.is_empty());
|
|
|
drop(tmp);
|
|
|
}
|
|
|
|
|
|
/// `find_nbd_root_from` finds `.nbd/` located in a grandparent directory.
|
|
|
#[test]
|
|
|
fn traversal_finds_nbd_in_grandparent() {
|
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
|
let root = tmp.path();
|
|
|
|
|
|
// Place `.nbd/` at the temp root.
|
|
|
std::fs::create_dir(root.join(".nbd")).unwrap();
|
|
|
|
|
|
// Start traversal from a deeply nested subdirectory.
|
|
|
let grandchild = root.join("a").join("b");
|
|
|
std::fs::create_dir_all(&grandchild).unwrap();
|
|
|
|
|
|
let found = find_nbd_root_from(&grandchild).unwrap();
|
|
|
assert_eq!(found, root);
|
|
|
drop(tmp);
|
|
|
}
|
|
|
|
|
|
/// `find_nbd_root_from` returns an error when no `.nbd/` directory exists.
|
|
|
#[test]
|
|
|
fn traversal_errors_when_no_nbd_dir() {
|
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
|
let result = find_nbd_root_from(tmp.path());
|
|
|
assert!(result.is_err());
|
|
|
drop(tmp);
|
|
|
}
|
|
|
|
|
|
/// Writing in TOML format and reading back produces an identical ticket.
|
|
|
#[tokio::test]
|
|
|
async fn write_and_read_roundtrip_toml() {
|
|
|
let (tmp, root) = setup_store().await;
|
|
|
let ticket = Ticket {
|
|
|
id: "aa1122".to_string(),
|
|
|
title: "TOML ticket".to_string(),
|
|
|
body: "Body text for TOML".to_string(),
|
|
|
priority: 7,
|
|
|
status: crate::ticket::Status::InProgress,
|
|
|
dependencies: vec!["bb2233".to_string()],
|
|
|
ticket_type: crate::ticket::TicketType::Feature,
|
|
|
};
|
|
|
write_ticket(&root, &ticket, FileFormat::Toml)
|
|
|
.await
|
|
|
.unwrap();
|
|
|
|
|
|
// The `.toml` file must exist; the `.json` file must not.
|
|
|
assert!(ticket_path(&root, "aa1122", FileFormat::Toml).is_file());
|
|
|
assert!(!ticket_path(&root, "aa1122", FileFormat::Json).is_file());
|
|
|
|
|
|
let restored = read_ticket(&root, "aa1122").await.unwrap();
|
|
|
assert_eq!(restored.id, ticket.id);
|
|
|
assert_eq!(restored.title, ticket.title);
|
|
|
assert_eq!(restored.body, ticket.body);
|
|
|
assert_eq!(restored.priority, ticket.priority);
|
|
|
assert_eq!(restored.status, ticket.status);
|
|
|
assert_eq!(restored.dependencies, ticket.dependencies);
|
|
|
assert_eq!(restored.ticket_type, ticket.ticket_type);
|
|
|
drop(tmp);
|
|
|
}
|
|
|
|
|
|
/// Writing in Markdown format and reading back produces an identical ticket.
|
|
|
#[tokio::test]
|
|
|
async fn write_and_read_roundtrip_markdown() {
|
|
|
let (tmp, root) = setup_store().await;
|
|
|
let ticket = Ticket {
|
|
|
id: "cc3344".to_string(),
|
|
|
title: "Markdown ticket".to_string(),
|
|
|
body: "# Header\n\nBody paragraph.".to_string(),
|
|
|
priority: 6,
|
|
|
status: crate::ticket::Status::Todo,
|
|
|
dependencies: vec![],
|
|
|
ticket_type: crate::ticket::TicketType::Bug,
|
|
|
};
|
|
|
write_ticket(&root, &ticket, FileFormat::Markdown)
|
|
|
.await
|
|
|
.unwrap();
|
|
|
|
|
|
assert!(ticket_path(&root, "cc3344", FileFormat::Markdown).is_file());
|
|
|
assert!(!ticket_path(&root, "cc3344", FileFormat::Json).is_file());
|
|
|
|
|
|
let restored = read_ticket(&root, "cc3344").await.unwrap();
|
|
|
assert_eq!(restored.id, ticket.id);
|
|
|
assert_eq!(restored.title, ticket.title);
|
|
|
assert_eq!(restored.body, ticket.body);
|
|
|
assert_eq!(restored.priority, ticket.priority);
|
|
|
assert_eq!(restored.status, ticket.status);
|
|
|
assert_eq!(restored.dependencies, ticket.dependencies);
|
|
|
assert_eq!(restored.ticket_type, ticket.ticket_type);
|
|
|
drop(tmp);
|
|
|
}
|
|
|
|
|
|
/// Writing in CBOR format and reading back produces an identical ticket.
|
|
|
#[tokio::test]
|
|
|
async fn write_and_read_roundtrip_jsonb() {
|
|
|
let (tmp, root) = setup_store().await;
|
|
|
let ticket = Ticket {
|
|
|
id: "ee5566".to_string(),
|
|
|
title: "CBOR ticket".to_string(),
|
|
|
body: "Binary body".to_string(),
|
|
|
priority: 4,
|
|
|
status: crate::ticket::Status::Done,
|
|
|
dependencies: vec!["ff6677".to_string(), "aa0011".to_string()],
|
|
|
ticket_type: crate::ticket::TicketType::Task,
|
|
|
};
|
|
|
write_ticket(&root, &ticket, FileFormat::Jsonb)
|
|
|
.await
|
|
|
.unwrap();
|
|
|
|
|
|
assert!(ticket_path(&root, "ee5566", FileFormat::Jsonb).is_file());
|
|
|
assert!(!ticket_path(&root, "ee5566", FileFormat::Json).is_file());
|
|
|
|
|
|
let restored = read_ticket(&root, "ee5566").await.unwrap();
|
|
|
assert_eq!(restored.id, ticket.id);
|
|
|
assert_eq!(restored.title, ticket.title);
|
|
|
assert_eq!(restored.body, ticket.body);
|
|
|
assert_eq!(restored.priority, ticket.priority);
|
|
|
assert_eq!(restored.status, ticket.status);
|
|
|
assert_eq!(restored.dependencies, ticket.dependencies);
|
|
|
assert_eq!(restored.ticket_type, ticket.ticket_type);
|
|
|
drop(tmp);
|
|
|
}
|
|
|
|
|
|
/// `ticket_path` returns the correct path for each file format.
|
|
|
#[test]
|
|
|
fn ticket_path_is_correct() {
|
|
|
let root = Path::new("/tmp/project");
|
|
|
assert_eq!(
|
|
|
ticket_path(root, "a3f9c2", FileFormat::Json),
|
|
|
Path::new("/tmp/project/.nbd/tickets/a3f9c2.json")
|
|
|
);
|
|
|
assert_eq!(
|
|
|
ticket_path(root, "a3f9c2", FileFormat::Markdown),
|
|
|
Path::new("/tmp/project/.nbd/tickets/a3f9c2.md")
|
|
|
);
|
|
|
assert_eq!(
|
|
|
ticket_path(root, "a3f9c2", FileFormat::Toml),
|
|
|
Path::new("/tmp/project/.nbd/tickets/a3f9c2.toml")
|
|
|
);
|
|
|
assert_eq!(
|
|
|
ticket_path(root, "a3f9c2", FileFormat::Jsonb),
|
|
|
Path::new("/tmp/project/.nbd/tickets/a3f9c2.jsonb")
|
|
|
);
|
|
|
}
|
|
|
|
|
|
/// `tickets_dir` returns the expected `.nbd/tickets/` path.
|
|
|
#[test]
|
|
|
fn tickets_dir_is_correct() {
|
|
|
let root = Path::new("/tmp/project");
|
|
|
let dir = tickets_dir(root);
|
|
|
assert_eq!(dir, Path::new("/tmp/project/.nbd/tickets"));
|
|
|
}
|
|
|
}
|
|
|
|
|
|
// ── migrate ───────────────────────────────────────────────────────────────────
|
|
|
|
|
|
/// Tests for [`crate::store::migrate_tickets`].
|
|
|
mod migrate {
|
|
|
use crate::filter::TicketFilter;
|
|
|
use crate::store::{
|
|
|
ensure_tickets_dir, migrate_tickets, tickets_dir, write_ticket, FileFormat,
|
|
|
};
|
|
|
use crate::ticket::Ticket;
|
|
|
|
|
|
async fn setup_store() -> (tempfile::TempDir, std::path::PathBuf) {
|
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
|
let root = tmp.path().to_path_buf();
|
|
|
ensure_tickets_dir(&root).await.unwrap();
|
|
|
(tmp, root)
|
|
|
}
|
|
|
|
|
|
/// `migrate_tickets` rewrites old-format files that contain a stale `"id"` key.
|
|
|
#[tokio::test]
|
|
|
async fn rewrites_old_format_with_id_field() {
|
|
|
let (tmp, root) = setup_store().await;
|
|
|
let dir = tickets_dir(&root);
|
|
|
// Write a file with the legacy "id" key.
|
|
|
let old_json = r#"{"id":"aabbcc","title":"Old","body":"","priority":5,"status":"todo","dependencies":[],"ticket_type":"task"}"#;
|
|
|
tokio::fs::write(dir.join("aabbcc.json"), old_json)
|
|
|
.await
|
|
|
.unwrap();
|
|
|
|
|
|
let report = migrate_tickets(&root, false, &TicketFilter::default())
|
|
|
.await
|
|
|
.unwrap();
|
|
|
assert_eq!(report.updated, 1);
|
|
|
assert_eq!(report.already_current, 0);
|
|
|
assert!(report.errors.is_empty());
|
|
|
|
|
|
// Verify the file no longer contains the "id" key.
|
|
|
let contents = tokio::fs::read_to_string(dir.join("aabbcc.json"))
|
|
|
.await
|
|
|
.unwrap();
|
|
|
let parsed: serde_json::Value = serde_json::from_str(&contents).unwrap();
|
|
|
assert!(
|
|
|
parsed.get("id").is_none(),
|
|
|
"migrated file must not contain 'id', got: {contents}"
|
|
|
);
|
|
|
drop(tmp);
|
|
|
}
|
|
|
|
|
|
/// `migrate_tickets` on a store with already-current files returns `updated: 0`.
|
|
|
#[tokio::test]
|
|
|
async fn already_current_files_not_rewritten() {
|
|
|
let (tmp, root) = setup_store().await;
|
|
|
let t1 = Ticket::new("id0001".to_string(), "First".to_string());
|
|
|
let t2 = Ticket::new("id0002".to_string(), "Second".to_string());
|
|
|
write_ticket(&root, &t1, FileFormat::Json).await.unwrap();
|
|
|
write_ticket(&root, &t2, FileFormat::Json).await.unwrap();
|
|
|
|
|
|
let report = migrate_tickets(&root, false, &TicketFilter::default())
|
|
|
.await
|
|
|
.unwrap();
|
|
|
assert_eq!(report.updated, 0);
|
|
|
assert_eq!(report.already_current, 2);
|
|
|
assert!(report.errors.is_empty());
|
|
|
drop(tmp);
|
|
|
}
|
|
|
|
|
|
/// `migrate_tickets` with `dry_run: true` does not write files.
|
|
|
#[tokio::test]
|
|
|
async fn dry_run_does_not_write() {
|
|
|
let (tmp, root) = setup_store().await;
|
|
|
let dir = tickets_dir(&root);
|
|
|
let old_json = r#"{"id":"ccddee","title":"DryRun","body":"","priority":5,"status":"todo","dependencies":[],"ticket_type":"task"}"#;
|
|
|
tokio::fs::write(dir.join("ccddee.json"), old_json)
|
|
|
.await
|
|
|
.unwrap();
|
|
|
|
|
|
let report = migrate_tickets(&root, true, &TicketFilter::default())
|
|
|
.await
|
|
|
.unwrap();
|
|
|
assert_eq!(
|
|
|
report.updated, 1,
|
|
|
"dry_run should still count as would-update"
|
|
|
);
|
|
|
assert!(report.errors.is_empty());
|
|
|
|
|
|
// File must remain unchanged.
|
|
|
let contents = tokio::fs::read_to_string(dir.join("ccddee.json"))
|
|
|
.await
|
|
|
.unwrap();
|
|
|
assert_eq!(
|
|
|
contents, old_json,
|
|
|
"dry_run must not modify the file on disk"
|
|
|
);
|
|
|
drop(tmp);
|
|
|
}
|
|
|
|
|
|
/// Invalid JSON files are counted in errors and left untouched.
|
|
|
#[tokio::test]
|
|
|
async fn invalid_json_counted_in_errors() {
|
|
|
let (tmp, root) = setup_store().await;
|
|
|
let dir = tickets_dir(&root);
|
|
|
let bad_json = b"{ this is not valid json }";
|
|
|
tokio::fs::write(dir.join("badbad.json"), bad_json)
|
|
|
.await
|
|
|
.unwrap();
|
|
|
|
|
|
let report = migrate_tickets(&root, false, &TicketFilter::default())
|
|
|
.await
|
|
|
.unwrap();
|
|
|
assert_eq!(report.errors.len(), 1);
|
|
|
assert!(report.errors[0].0.contains("badbad.json"));
|
|
|
|
|
|
// File must remain unchanged.
|
|
|
let contents = tokio::fs::read(&dir.join("badbad.json")).await.unwrap();
|
|
|
assert_eq!(contents.as_slice(), bad_json);
|
|
|
drop(tmp);
|
|
|
}
|
|
|
|
|
|
/// `migrate_tickets` on an empty store returns an empty report.
|
|
|
#[tokio::test]
|
|
|
async fn empty_store_returns_empty_report() {
|
|
|
let (tmp, root) = setup_store().await;
|
|
|
let report = migrate_tickets(&root, false, &TicketFilter::default())
|
|
|
.await
|
|
|
.unwrap();
|
|
|
assert_eq!(report.updated, 0);
|
|
|
assert_eq!(report.already_current, 0);
|
|
|
assert!(report.errors.is_empty());
|
|
|
drop(tmp);
|
|
|
}
|
|
|
|
|
|
/// `migrate_tickets` returns an empty report when the tickets directory does not exist.
|
|
|
#[tokio::test]
|
|
|
async fn no_tickets_dir_returns_empty_report() {
|
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
|
// Create `.nbd/` but not `.nbd/tickets/`.
|
|
|
std::fs::create_dir(tmp.path().join(".nbd")).unwrap();
|
|
|
let root = tmp.path().to_path_buf();
|
|
|
|
|
|
let report = migrate_tickets(&root, false, &TicketFilter::default())
|
|
|
.await
|
|
|
.unwrap();
|
|
|
assert_eq!(report.updated, 0);
|
|
|
assert_eq!(report.already_current, 0);
|
|
|
assert!(report.errors.is_empty());
|
|
|
drop(tmp);
|
|
|
}
|
|
|
}
|
|
|
|
|
|
// ── filter module ────────────────────────────────────────────────────────────
|
|
|
|
|
|
/// Tests for [`crate::filter`].
|
|
|
mod filter {
|
|
|
use crate::filter::{glob_matches, parse_filters, TicketFilter};
|
|
|
use crate::ticket::{Status, Ticket, TicketType};
|
|
|
|
|
|
fn make_ticket(status: Status, ticket_type: TicketType, priority: u8, title: &str) -> Ticket {
|
|
|
let mut t = Ticket::new("aabbcc".to_string(), title.to_string());
|
|
|
t.status = status;
|
|
|
t.ticket_type = ticket_type;
|
|
|
t.priority = priority;
|
|
|
t
|
|
|
}
|
|
|
|
|
|
// ── parse_filters ──────────────────────────────────────────────────────
|
|
|
|
|
|
/// `parse_filters` returns an error for a string that contains no `=`.
|
|
|
#[test]
|
|
|
fn parse_filters_rejects_no_equals() {
|
|
|
let args = vec!["statusbad".to_string()];
|
|
|
let result = parse_filters(&args);
|
|
|
assert!(result.is_err());
|
|
|
let msg = result.unwrap_err().to_string();
|
|
|
assert!(
|
|
|
msg.contains("key=value"),
|
|
|
"error should mention expected format, got: {msg}"
|
|
|
);
|
|
|
}
|
|
|
|
|
|
/// `parse_filters` returns an error for an unknown key.
|
|
|
#[test]
|
|
|
fn parse_filters_rejects_unknown_key() {
|
|
|
let args = vec!["colour=red".to_string()];
|
|
|
let result = parse_filters(&args);
|
|
|
assert!(result.is_err());
|
|
|
let msg = result.unwrap_err().to_string();
|
|
|
assert!(
|
|
|
msg.contains("colour"),
|
|
|
"error should mention the unknown key, got: {msg}"
|
|
|
);
|
|
|
}
|
|
|
|
|
|
/// `parse_filters` treats everything after the first `=` as the value.
|
|
|
#[test]
|
|
|
fn parse_filters_value_contains_equals() {
|
|
|
let args = vec!["title=foo=bar".to_string()];
|
|
|
let filter = parse_filters(&args).expect("should succeed");
|
|
|
assert_eq!(filter.title, ["foo=bar"]);
|
|
|
}
|
|
|
|
|
|
/// `parse_filters` correctly populates the `status` and `ticket_type` vecs.
|
|
|
#[test]
|
|
|
fn parse_filters_basic() {
|
|
|
let args = vec!["status=todo".to_string(), "type=bug".to_string()];
|
|
|
let filter = parse_filters(&args).expect("should succeed");
|
|
|
assert_eq!(filter.status, ["todo"]);
|
|
|
assert_eq!(filter.ticket_type, ["bug"]);
|
|
|
assert!(filter.priority.is_empty());
|
|
|
assert!(filter.title.is_empty());
|
|
|
}
|
|
|
|
|
|
// ── glob_matches ──────────────────────────────────────────────────────
|
|
|
|
|
|
/// `*` matches any non-empty string.
|
|
|
#[test]
|
|
|
fn glob_star_matches_anything() {
|
|
|
assert!(glob_matches("*", "anything"));
|
|
|
}
|
|
|
|
|
|
/// `*` matches the empty string.
|
|
|
#[test]
|
|
|
fn glob_star_matches_empty() {
|
|
|
assert!(glob_matches("*", ""));
|
|
|
}
|
|
|
|
|
|
/// An exact pattern matches identical input.
|
|
|
#[test]
|
|
|
fn glob_exact_match() {
|
|
|
assert!(glob_matches("todo", "todo"));
|
|
|
}
|
|
|
|
|
|
/// An exact pattern does not match a different string.
|
|
|
#[test]
|
|
|
fn glob_exact_no_match() {
|
|
|
assert!(!glob_matches("todo", "done"));
|
|
|
}
|
|
|
|
|
|
/// `*command*` matches a string that contains "command".
|
|
|
#[test]
|
|
|
fn glob_contains_match() {
|
|
|
assert!(glob_matches("*command*", "add command here"));
|
|
|
}
|
|
|
|
|
|
/// `*command*` does not match a string that lacks "command".
|
|
|
#[test]
|
|
|
fn glob_contains_no_match() {
|
|
|
assert!(!glob_matches("*command*", "no match"));
|
|
|
}
|
|
|
|
|
|
/// `in_*` matches a string that starts with "in_".
|
|
|
#[test]
|
|
|
fn glob_prefix_match() {
|
|
|
assert!(glob_matches("in_*", "in_progress"));
|
|
|
}
|
|
|
|
|
|
/// `in_*` does not match a string that does not start with "in_".
|
|
|
#[test]
|
|
|
fn glob_prefix_no_match() {
|
|
|
assert!(!glob_matches("in_*", "todo"));
|
|
|
}
|
|
|
|
|
|
// ── TicketFilter::matches ─────────────────────────────────────────────
|
|
|
|
|
|
/// An empty filter matches every ticket.
|
|
|
#[test]
|
|
|
fn empty_filter_matches_everything() {
|
|
|
let filter = TicketFilter::default();
|
|
|
let ticket = make_ticket(Status::Todo, TicketType::Bug, 5, "Any title");
|
|
|
assert!(filter.matches(&ticket));
|
|
|
}
|
|
|
|
|
|
/// Different keys are ANDed: both must match.
|
|
|
#[test]
|
|
|
fn different_keys_are_anded() {
|
|
|
// Filter: type=bug AND status=todo
|
|
|
let args = vec!["type=bug".to_string(), "status=todo".to_string()];
|
|
|
let filter = parse_filters(&args).unwrap();
|
|
|
|
|
|
// Matches: bug + todo
|
|
|
let ticket = make_ticket(Status::Todo, TicketType::Bug, 5, "A bug");
|
|
|
assert!(filter.matches(&ticket));
|
|
|
|
|
|
// Doesn't match: feature + todo (wrong type)
|
|
|
let ticket2 = make_ticket(Status::Todo, TicketType::Feature, 5, "A feature");
|
|
|
assert!(!filter.matches(&ticket2));
|
|
|
|
|
|
// Doesn't match: bug + done (wrong status)
|
|
|
let ticket3 = make_ticket(Status::Done, TicketType::Bug, 5, "Done bug");
|
|
|
assert!(!filter.matches(&ticket3));
|
|
|
}
|
|
|
|
|
|
/// Same key with multiple values are ORed: either match passes.
|
|
|
#[test]
|
|
|
fn same_key_is_ored() {
|
|
|
// Filter: status=todo OR status=in_progress
|
|
|
let args = vec!["status=todo".to_string(), "status=in_progress".to_string()];
|
|
|
let filter = parse_filters(&args).unwrap();
|
|
|
|
|
|
let todo_ticket = make_ticket(Status::Todo, TicketType::Task, 5, "Todo");
|
|
|
let inprogress_ticket = make_ticket(Status::InProgress, TicketType::Task, 5, "In progress");
|
|
|
let done_ticket = make_ticket(Status::Done, TicketType::Task, 5, "Done");
|
|
|
|
|
|
assert!(filter.matches(&todo_ticket));
|
|
|
assert!(filter.matches(&inprogress_ticket));
|
|
|
assert!(!filter.matches(&done_ticket));
|
|
|
}
|
|
|
|
|
|
// ── TicketFilter::is_empty ─────────────────────────────────────────────
|
|
|
|
|
|
/// `is_empty` returns `true` for a default filter.
|
|
|
#[test]
|
|
|
fn is_empty_true_when_no_filters() {
|
|
|
let filter = TicketFilter::default();
|
|
|
assert!(filter.is_empty());
|
|
|
}
|
|
|
|
|
|
/// `is_empty` returns `false` when any filter is set.
|
|
|
#[test]
|
|
|
fn is_empty_false_when_filter_set() {
|
|
|
let mut filter = TicketFilter::default();
|
|
|
filter.status.push("todo".to_string());
|
|
|
assert!(!filter.is_empty());
|
|
|
}
|
|
|
|
|
|
// ── TicketFilter::has_status_filter ───────────────────────────────────
|
|
|
|
|
|
/// `has_status_filter` returns `false` for a default filter.
|
|
|
#[test]
|
|
|
fn has_status_filter_false_by_default() {
|
|
|
let filter = TicketFilter::default();
|
|
|
assert!(!filter.has_status_filter());
|
|
|
}
|
|
|
|
|
|
/// `has_status_filter` returns `true` when a status pattern is present.
|
|
|
#[test]
|
|
|
fn has_status_filter_true_when_status_set() {
|
|
|
let args = vec!["status=todo".to_string()];
|
|
|
let filter = parse_filters(&args).unwrap();
|
|
|
assert!(filter.has_status_filter());
|
|
|
}
|
|
|
|
|
|
/// `has_status_filter` returns `false` when only non-status filters are set.
|
|
|
#[test]
|
|
|
fn has_status_filter_false_when_only_type_set() {
|
|
|
let args = vec!["type=bug".to_string()];
|
|
|
let filter = parse_filters(&args).unwrap();
|
|
|
assert!(!filter.has_status_filter());
|
|
|
}
|
|
|
|
|
|
// ── TicketFilter::matches_status ──────────────────────────────────────
|
|
|
|
|
|
/// `matches_status` returns `true` when the ticket's status matches a pattern.
|
|
|
#[test]
|
|
|
fn matches_status_matches_exact() {
|
|
|
let args = vec!["status=todo".to_string()];
|
|
|
let filter = parse_filters(&args).unwrap();
|
|
|
let ticket = make_ticket(Status::Todo, TicketType::Task, 5, "A ticket");
|
|
|
assert!(filter.matches_status(&ticket));
|
|
|
}
|
|
|
|
|
|
/// `matches_status` returns `false` when the ticket's status does not match.
|
|
|
#[test]
|
|
|
fn matches_status_no_match() {
|
|
|
let args = vec!["status=done".to_string()];
|
|
|
let filter = parse_filters(&args).unwrap();
|
|
|
let ticket = make_ticket(Status::Todo, TicketType::Task, 5, "A ticket");
|
|
|
assert!(!filter.matches_status(&ticket));
|
|
|
}
|
|
|
|
|
|
/// `matches_status` with `status=*` matches any status including `closed` and `backlog`.
|
|
|
#[test]
|
|
|
fn matches_status_wildcard_matches_all() {
|
|
|
let args = vec!["status=*".to_string()];
|
|
|
let filter = parse_filters(&args).unwrap();
|
|
|
assert!(filter.matches_status(&make_ticket(Status::Todo, TicketType::Task, 5, "T")));
|
|
|
assert!(filter.matches_status(&make_ticket(Status::InProgress, TicketType::Task, 5, "T")));
|
|
|
assert!(filter.matches_status(&make_ticket(Status::Done, TicketType::Task, 5, "T")));
|
|
|
assert!(filter.matches_status(&make_ticket(Status::Closed, TicketType::Task, 5, "T")));
|
|
|
assert!(filter.matches_status(&make_ticket(Status::Backlog, TicketType::Task, 5, "T")));
|
|
|
}
|
|
|
|
|
|
/// `matches_status` with `status=backlog` matches only backlog tickets.
|
|
|
#[test]
|
|
|
fn matches_status_backlog_pattern() {
|
|
|
let args = vec!["status=backlog".to_string()];
|
|
|
let filter = parse_filters(&args).unwrap();
|
|
|
assert!(!filter.matches_status(&make_ticket(Status::Todo, TicketType::Task, 5, "T")));
|
|
|
assert!(!filter.matches_status(&make_ticket(Status::InProgress, TicketType::Task, 5, "T")));
|
|
|
assert!(!filter.matches_status(&make_ticket(Status::Done, TicketType::Task, 5, "T")));
|
|
|
assert!(!filter.matches_status(&make_ticket(Status::Closed, TicketType::Task, 5, "T")));
|
|
|
assert!(filter.matches_status(&make_ticket(Status::Backlog, TicketType::Task, 5, "T")));
|
|
|
}
|
|
|
|
|
|
/// `matches_status` with `status=closed` matches only closed tickets.
|
|
|
#[test]
|
|
|
fn matches_status_closed_pattern() {
|
|
|
let args = vec!["status=closed".to_string()];
|
|
|
let filter = parse_filters(&args).unwrap();
|
|
|
assert!(!filter.matches_status(&make_ticket(Status::Todo, TicketType::Task, 5, "T")));
|
|
|
assert!(!filter.matches_status(&make_ticket(Status::InProgress, TicketType::Task, 5, "T")));
|
|
|
assert!(!filter.matches_status(&make_ticket(Status::Done, TicketType::Task, 5, "T")));
|
|
|
assert!(filter.matches_status(&make_ticket(Status::Closed, TicketType::Task, 5, "T")));
|
|
|
}
|
|
|
|
|
|
/// `matches_status` ORs multiple status patterns.
|
|
|
#[test]
|
|
|
fn matches_status_ored_patterns() {
|
|
|
let args = vec!["status=todo".to_string(), "status=in_progress".to_string()];
|
|
|
let filter = parse_filters(&args).unwrap();
|
|
|
assert!(filter.matches_status(&make_ticket(Status::Todo, TicketType::Task, 5, "T")));
|
|
|
assert!(filter.matches_status(&make_ticket(Status::InProgress, TicketType::Task, 5, "T")));
|
|
|
assert!(!filter.matches_status(&make_ticket(Status::Done, TicketType::Task, 5, "T")));
|
|
|
}
|
|
|
|
|
|
// ── TicketFilter::matches_except_status ───────────────────────────────
|
|
|
|
|
|
/// `matches_except_status` with an empty filter always returns `true`.
|
|
|
#[test]
|
|
|
fn matches_except_status_empty_filter_always_true() {
|
|
|
let filter = TicketFilter::default();
|
|
|
let ticket = make_ticket(Status::Done, TicketType::Bug, 5, "Any");
|
|
|
assert!(filter.matches_except_status(&ticket));
|
|
|
}
|
|
|
|
|
|
/// `matches_except_status` matches on type while ignoring status.
|
|
|
#[test]
|
|
|
fn matches_except_status_checks_type_not_status() {
|
|
|
let args = vec!["type=bug".to_string()];
|
|
|
let filter = parse_filters(&args).unwrap();
|
|
|
|
|
|
// Done bug: type matches even though status is done.
|
|
|
let done_bug = make_ticket(Status::Done, TicketType::Bug, 5, "Bug");
|
|
|
assert!(filter.matches_except_status(&done_bug));
|
|
|
|
|
|
// Todo task: type does not match.
|
|
|
let todo_task = make_ticket(Status::Todo, TicketType::Task, 5, "Task");
|
|
|
assert!(!filter.matches_except_status(&todo_task));
|
|
|
}
|
|
|
|
|
|
/// `matches_except_status` a status-only filter always returns `true`.
|
|
|
#[test]
|
|
|
fn matches_except_status_status_only_filter_returns_true() {
|
|
|
let args = vec!["status=done".to_string()];
|
|
|
let filter = parse_filters(&args).unwrap();
|
|
|
// matches_except_status ignores the status group.
|
|
|
let todo_ticket = make_ticket(Status::Todo, TicketType::Task, 5, "T");
|
|
|
assert!(filter.matches_except_status(&todo_ticket));
|
|
|
}
|
|
|
}
|
|
|
|
|
|
// ── graph module ──────────────────────────────────────────────────────────────
|
|
|
|
|
|
/// Tests for [`crate::graph`].
|
|
|
mod graph {
|
|
|
use crate::graph::TicketGraph;
|
|
|
use crate::ticket::Ticket;
|
|
|
|
|
|
/// Build a ticket with a given ID, title, and dependency list.
|
|
|
fn make_ticket(id: &str, deps: &[&str]) -> Ticket {
|
|
|
let mut t = Ticket::new(id.to_string(), format!("Ticket {id}"));
|
|
|
t.dependencies = deps.iter().map(|d| d.to_string()).collect();
|
|
|
t
|
|
|
}
|
|
|
|
|
|
/// `build` on an empty slice produces a graph with no nodes.
|
|
|
#[test]
|
|
|
fn build_empty() {
|
|
|
let graph = TicketGraph::build(&[]);
|
|
|
assert!(graph.roots().is_empty());
|
|
|
assert!(graph.subtree("anything").is_empty());
|
|
|
}
|
|
|
|
|
|
/// Two tickets with no dependencies are both roots.
|
|
|
#[test]
|
|
|
fn roots_no_deps() {
|
|
|
let tickets = vec![make_ticket("aaaaaa", &[]), make_ticket("bbbbbb", &[])];
|
|
|
let graph = TicketGraph::build(&tickets);
|
|
|
let roots = graph.roots();
|
|
|
assert_eq!(roots.len(), 2);
|
|
|
let ids: Vec<&str> = roots.iter().map(|t| t.id.as_str()).collect();
|
|
|
assert!(ids.contains(&"aaaaaa"));
|
|
|
assert!(ids.contains(&"bbbbbb"));
|
|
|
}
|
|
|
|
|
|
/// When B depends on A, only B is a root (B is the goal; A is a prerequisite).
|
|
|
#[test]
|
|
|
fn roots_with_chain() {
|
|
|
let a = make_ticket("aaaaaa", &[]);
|
|
|
let b = make_ticket("bbbbbb", &["aaaaaa"]);
|
|
|
let tickets = vec![a, b];
|
|
|
let graph = TicketGraph::build(&tickets);
|
|
|
let roots = graph.roots();
|
|
|
assert_eq!(roots.len(), 1);
|
|
|
assert_eq!(roots[0].id, "bbbbbb");
|
|
|
}
|
|
|
|
|
|
/// Roots are sorted by priority descending.
|
|
|
#[test]
|
|
|
fn roots_sorted_by_priority() {
|
|
|
let mut lo = make_ticket("aaaaaa", &[]);
|
|
|
lo.priority = 2;
|
|
|
let mut hi = make_ticket("bbbbbb", &[]);
|
|
|
hi.priority = 8;
|
|
|
let tickets = vec![lo, hi];
|
|
|
let graph = TicketGraph::build(&tickets);
|
|
|
let roots = graph.roots();
|
|
|
assert_eq!(roots[0].id, "bbbbbb", "highest priority root first");
|
|
|
assert_eq!(roots[1].id, "aaaaaa");
|
|
|
}
|
|
|
|
|
|
/// `subtree` on a linear chain A → B → C (B depends on A, C depends on B)
|
|
|
/// returns all three IDs when starting from C (the top-level goal).
|
|
|
#[test]
|
|
|
fn subtree_linear_chain() {
|
|
|
let a = make_ticket("aaaaaa", &[]);
|
|
|
let b = make_ticket("bbbbbb", &["aaaaaa"]);
|
|
|
let c = make_ticket("cccccc", &["bbbbbb"]);
|
|
|
let tickets = vec![a, b, c];
|
|
|
let graph = TicketGraph::build(&tickets);
|
|
|
|
|
|
let sub = graph.subtree("cccccc");
|
|
|
assert_eq!(sub.len(), 3, "subtree should include all three tickets");
|
|
|
assert!(sub.contains(&"aaaaaa"));
|
|
|
assert!(sub.contains(&"bbbbbb"));
|
|
|
assert!(sub.contains(&"cccccc"));
|
|
|
}
|
|
|
|
|
|
/// `subtree` on a leaf node (no dependencies) returns just that ID.
|
|
|
#[test]
|
|
|
fn subtree_leaf() {
|
|
|
let a = make_ticket("aaaaaa", &[]);
|
|
|
let b = make_ticket("bbbbbb", &["aaaaaa"]);
|
|
|
let tickets = vec![a, b];
|
|
|
let graph = TicketGraph::build(&tickets);
|
|
|
|
|
|
let sub = graph.subtree("aaaaaa");
|
|
|
assert_eq!(sub, vec!["aaaaaa"]);
|
|
|
}
|
|
|
|
|
|
/// `subtree` on an unknown ID returns an empty vec.
|
|
|
#[test]
|
|
|
fn subtree_unknown_id() {
|
|
|
let tickets = vec![make_ticket("aaaaaa", &[])];
|
|
|
let graph = TicketGraph::build(&tickets);
|
|
|
assert!(graph.subtree("ffffff").is_empty());
|
|
|
}
|
|
|
|
|
|
/// `subtree` does not infinite-loop when the data contains a cycle.
|
|
|
#[test]
|
|
|
fn subtree_cycle_safe() {
|
|
|
let mut a = make_ticket("aaaaaa", &["bbbbbb"]);
|
|
|
a.dependencies = vec!["bbbbbb".to_string()];
|
|
|
let mut b = make_ticket("bbbbbb", &["aaaaaa"]);
|
|
|
b.dependencies = vec!["aaaaaa".to_string()];
|
|
|
let tickets = vec![a, b];
|
|
|
let graph = TicketGraph::build(&tickets);
|
|
|
|
|
|
// Must not hang; both IDs should appear exactly once.
|
|
|
let sub = graph.subtree("aaaaaa");
|
|
|
assert!(sub.contains(&"aaaaaa"));
|
|
|
assert!(sub.contains(&"bbbbbb"));
|
|
|
assert_eq!(sub.len(), 2, "each ID should appear exactly once");
|
|
|
}
|
|
|
|
|
|
/// `to_json_value` includes all tickets as nodes and all in-graph edges.
|
|
|
#[test]
|
|
|
fn to_json_value_nodes_and_edges() {
|
|
|
let a = make_ticket("aaaaaa", &[]);
|
|
|
let b = make_ticket("bbbbbb", &["aaaaaa"]);
|
|
|
let c = make_ticket("cccccc", &["aaaaaa"]);
|
|
|
let tickets = vec![a, b, c];
|
|
|
let graph = TicketGraph::build(&tickets);
|
|
|
|
|
|
let json = graph.to_json_value();
|
|
|
let nodes = json["nodes"].as_array().unwrap();
|
|
|
let edges = json["edges"].as_array().unwrap();
|
|
|
|
|
|
assert_eq!(nodes.len(), 3, "all three tickets should be nodes");
|
|
|
assert_eq!(edges.len(), 2, "two edges: aaaaaa→bbbbbb and aaaaaa→cccccc");
|
|
|
|
|
|
let node_ids: Vec<&str> = nodes.iter().map(|n| n["id"].as_str().unwrap()).collect();
|
|
|
assert!(node_ids.contains(&"aaaaaa"));
|
|
|
assert!(node_ids.contains(&"bbbbbb"));
|
|
|
assert!(node_ids.contains(&"cccccc"));
|
|
|
|
|
|
// Every edge should have "to" (dependency/prerequisite) = "aaaaaa".
|
|
|
for edge in edges {
|
|
|
assert_eq!(edge["to"].as_str().unwrap(), "aaaaaa");
|
|
|
}
|
|
|
}
|
|
|
|
|
|
/// `to_json_value` on an empty graph returns empty `nodes` and `edges`.
|
|
|
#[test]
|
|
|
fn to_json_value_empty() {
|
|
|
let graph = TicketGraph::build(&[]);
|
|
|
let json = graph.to_json_value();
|
|
|
assert!(json["nodes"].as_array().unwrap().is_empty());
|
|
|
assert!(json["edges"].as_array().unwrap().is_empty());
|
|
|
}
|
|
|
|
|
|
/// `to_subtree_json_value` limits nodes and edges to the reachable subtree.
|
|
|
#[test]
|
|
|
fn to_subtree_json_value_scoped() {
|
|
|
let a = make_ticket("aaaaaa", &[]);
|
|
|
let b = make_ticket("bbbbbb", &["aaaaaa"]); // depends on a
|
|
|
let c = make_ticket("cccccc", &[]); // unrelated
|
|
|
let tickets = vec![a, b, c];
|
|
|
let graph = TicketGraph::build(&tickets);
|
|
|
|
|
|
// Starting from bbbbbb (the goal): subtree includes bbbbbb + its dependency aaaaaa.
|
|
|
let json = graph.to_subtree_json_value("bbbbbb");
|
|
|
let nodes = json["nodes"].as_array().unwrap();
|
|
|
let node_ids: Vec<&str> = nodes.iter().map(|n| n["id"].as_str().unwrap()).collect();
|
|
|
|
|
|
assert!(node_ids.contains(&"bbbbbb"), "root should be included");
|
|
|
assert!(
|
|
|
node_ids.contains(&"aaaaaa"),
|
|
|
"dependency should be included"
|
|
|
);
|
|
|
assert!(
|
|
|
!node_ids.contains(&"cccccc"),
|
|
|
"unrelated ticket should be excluded"
|
|
|
);
|
|
|
}
|
|
|
|
|
|
/// Dangling dependency references (IDs not in the graph) are silently ignored.
|
|
|
#[test]
|
|
|
fn dangling_deps_ignored() {
|
|
|
let mut a = make_ticket("aaaaaa", &[]);
|
|
|
// "ffffff" does not exist in the graph.
|
|
|
a.dependencies = vec!["ffffff".to_string()];
|
|
|
let tickets = vec![a];
|
|
|
let graph = TicketGraph::build(&tickets);
|
|
|
|
|
|
// aaaaaa has no in-graph dependencies, so it should be a root.
|
|
|
let roots = graph.roots();
|
|
|
assert_eq!(roots.len(), 1);
|
|
|
assert_eq!(roots[0].id, "aaaaaa");
|
|
|
|
|
|
// The JSON should show an empty dependencies list.
|
|
|
let json = graph.to_json_value();
|
|
|
let deps = json["nodes"][0]["dependencies"].as_array().unwrap();
|
|
|
assert!(deps.is_empty());
|
|
|
}
|
|
|
}
|
|
|
|
|
|
// ── display graph rendering ───────────────────────────────────────────────────
|
|
|
|
|
|
/// Tests for the graph rendering functions in [`crate::display`].
|
|
|
mod display_graph {
|
|
|
use crate::display::{format_graph, format_subtree};
|
|
|
use crate::graph::TicketGraph;
|
|
|
use crate::ticket::Ticket;
|
|
|
|
|
|
fn make_ticket(id: &str, deps: &[&str]) -> Ticket {
|
|
|
let mut t = Ticket::new(id.to_string(), format!("Ticket {id}"));
|
|
|
t.dependencies = deps.iter().map(|d| d.to_string()).collect();
|
|
|
t
|
|
|
}
|
|
|
|
|
|
/// A single ticket with no deps renders as one line containing its ID,
|
|
|
/// status, and title, with no box-drawing characters.
|
|
|
#[test]
|
|
|
fn single_ticket_no_deps() {
|
|
|
let tickets = vec![make_ticket("aaaaaa", &[])];
|
|
|
let graph = TicketGraph::build(&tickets);
|
|
|
let out = format_graph(&graph);
|
|
|
assert!(out.contains("aaaaaa"), "should contain ID");
|
|
|
assert!(out.contains("[todo]"), "should contain status");
|
|
|
assert!(out.contains("Ticket aaaaaa"), "should contain title");
|
|
|
assert!(!out.contains("├──"), "should have no branch connectors");
|
|
|
assert!(!out.contains("└──"), "should have no branch connectors");
|
|
|
}
|
|
|
|
|
|
/// A two-ticket chain (B depends on A) renders B at the top level (the goal)
|
|
|
/// and A indented below it with `└──` (the prerequisite).
|
|
|
#[test]
|
|
|
fn two_ticket_chain() {
|
|
|
let a = make_ticket("aaaaaa", &[]);
|
|
|
let b = make_ticket("bbbbbb", &["aaaaaa"]);
|
|
|
let tickets = vec![a, b];
|
|
|
let graph = TicketGraph::build(&tickets);
|
|
|
let out = format_graph(&graph);
|
|
|
|
|
|
// B should appear before A (B is the goal, A is the prerequisite).
|
|
|
let pos_a = out.find("aaaaaa").expect("aaaaaa should appear");
|
|
|
let pos_b = out.find("bbbbbb").expect("bbbbbb should appear");
|
|
|
assert!(
|
|
|
pos_b < pos_a,
|
|
|
"goal (bbbbbb) should appear before prerequisite (aaaaaa)"
|
|
|
);
|
|
|
|
|
|
// A's line should use the └── connector.
|
|
|
assert!(out.contains("└──"), "last (only) child should use └──");
|
|
|
assert!(!out.contains("├──"), "only child should not use ├──");
|
|
|
}
|
|
|
|
|
|
/// When a goal depends on two prerequisites, the first uses `├──` and the last `└──`.
|
|
|
#[test]
|
|
|
fn branching_goal() {
|
|
|
let a = make_ticket("aaaaaa", &[]);
|
|
|
let b = make_ticket("bbbbbb", &[]);
|
|
|
// cccccc depends on both aaaaaa and bbbbbb, so it renders with two children.
|
|
|
let c = make_ticket("cccccc", &["aaaaaa", "bbbbbb"]);
|
|
|
let tickets = vec![a, b, c];
|
|
|
let graph = TicketGraph::build(&tickets);
|
|
|
let out = format_graph(&graph);
|
|
|
|
|
|
assert!(out.contains("├──"), "non-last child should use ├──");
|
|
|
assert!(out.contains("└──"), "last child should use └──");
|
|
|
}
|
|
|
|
|
|
/// `format_subtree` for a goal includes that goal and its dependencies,
|
|
|
/// not unrelated tickets.
|
|
|
#[test]
|
|
|
fn subtree_excludes_unrelated() {
|
|
|
let a = make_ticket("aaaaaa", &[]);
|
|
|
let b = make_ticket("bbbbbb", &["aaaaaa"]);
|
|
|
let c = make_ticket("cccccc", &[]); // unrelated
|
|
|
let tickets = vec![a, b, c];
|
|
|
let graph = TicketGraph::build(&tickets);
|
|
|
|
|
|
// Starting from bbbbbb (the goal): renders bbbbbb and its dependency aaaaaa.
|
|
|
let out = format_subtree(&graph, "bbbbbb");
|
|
|
assert!(out.contains("bbbbbb"), "goal should be present");
|
|
|
assert!(out.contains("aaaaaa"), "dependency should be present");
|
|
|
assert!(!out.contains("cccccc"), "unrelated ticket should be absent");
|
|
|
}
|
|
|
|
|
|
/// `format_subtree` on an unknown ID returns an empty string.
|
|
|
#[test]
|
|
|
fn subtree_unknown_id_empty() {
|
|
|
let tickets = vec![make_ticket("aaaaaa", &[])];
|
|
|
let graph = TicketGraph::build(&tickets);
|
|
|
assert!(format_subtree(&graph, "ffffff").is_empty());
|
|
|
}
|
|
|
|
|
|
/// When the data contains a cycle, the repeated node is marked with `*`
|
|
|
/// and the output is finite (no infinite loop).
|
|
|
///
|
|
|
/// A pure cycle (A depends on B, B depends on A) has no roots, so we use
|
|
|
/// `format_subtree` to trigger rendering from one of the cyclic nodes.
|
|
|
#[test]
|
|
|
fn cycle_labelled() {
|
|
|
let mut a = make_ticket("aaaaaa", &[]);
|
|
|
a.dependencies = vec!["bbbbbb".to_string()];
|
|
|
let mut b = make_ticket("bbbbbb", &[]);
|
|
|
b.dependencies = vec!["aaaaaa".to_string()];
|
|
|
let tickets = vec![a, b];
|
|
|
let graph = TicketGraph::build(&tickets);
|
|
|
// format_subtree drives rendering from "aaaaaa"; when it tries to
|
|
|
// revisit "aaaaaa" via bbbbbb's dependency edge, it should mark with *.
|
|
|
let out = format_subtree(&graph, "aaaaaa");
|
|
|
assert!(
|
|
|
out.contains(" *"),
|
|
|
"repeated node should be marked with *: {out}"
|
|
|
);
|
|
|
}
|
|
|
|
|
|
/// An empty graph renders as an empty string.
|
|
|
#[test]
|
|
|
fn empty_graph() {
|
|
|
let graph = TicketGraph::build(&[]);
|
|
|
assert!(format_graph(&graph).is_empty());
|
|
|
}
|
|
|
}
|
|
|
|
|
|
// ── display module ────────────────────────────────────────────────────────────
|
|
|
|
|
|
/// Tests for [`crate::display`].
|
|
|
mod display {
|
|
|
use crate::display::{
|
|
|
format_diff, format_list, format_list_json, format_migrate_report,
|
|
|
format_migrate_report_json, format_ticket, format_ticket_json,
|
|
|
};
|
|
|
use crate::store::MigrateReport;
|
|
|
use crate::ticket::{Status, Ticket, TicketType};
|
|
|
|
|
|
/// Build a fully-populated ticket for use in display tests.
|
|
|
fn sample_ticket() -> Ticket {
|
|
|
Ticket {
|
|
|
id: "a3f9c2".to_string(),
|
|
|
title: "Fix login bug".to_string(),
|
|
|
body: "Users cannot log in with email addresses containing +".to_string(),
|
|
|
priority: 8,
|
|
|
status: Status::InProgress,
|
|
|
dependencies: vec!["b7d41e".to_string(), "c9e823".to_string()],
|
|
|
ticket_type: TicketType::Bug,
|
|
|
}
|
|
|
}
|
|
|
|
|
|
/// `format_ticket` includes all field values in its output.
|
|
|
#[test]
|
|
|
fn format_ticket_contains_all_fields() {
|
|
|
let t = sample_ticket();
|
|
|
let output = format_ticket(&t);
|
|
|
assert!(output.contains("a3f9c2"), "should contain ID");
|
|
|
assert!(output.contains("Fix login bug"), "should contain title");
|
|
|
assert!(
|
|
|
output.contains("Users cannot log in"),
|
|
|
"should contain body"
|
|
|
);
|
|
|
assert!(output.contains('8'), "should contain priority");
|
|
|
assert!(output.contains("in_progress"), "should contain status");
|
|
|
assert!(output.contains("bug"), "should contain ticket type");
|
|
|
assert!(output.contains("b7d41e"), "should contain first dependency");
|
|
|
assert!(
|
|
|
output.contains("c9e823"),
|
|
|
"should contain second dependency"
|
|
|
);
|
|
|
}
|
|
|
|
|
|
/// `format_ticket` renders dependency IDs as a TOML array.
|
|
|
#[test]
|
|
|
fn format_ticket_joins_dependencies() {
|
|
|
let t = sample_ticket();
|
|
|
let output = format_ticket(&t);
|
|
|
assert!(
|
|
|
output.contains("b7d41e") && output.contains("c9e823"),
|
|
|
"dependencies should appear in frontmatter: {output}"
|
|
|
);
|
|
|
// TOML array format: dependencies = ["b7d41e", "c9e823"]
|
|
|
assert!(
|
|
|
output.contains("dependencies"),
|
|
|
"dependencies key should appear: {output}"
|
|
|
);
|
|
|
}
|
|
|
|
|
|
/// `format_ticket` renders a `dependencies` key even when there are none.
|
|
|
#[test]
|
|
|
fn format_ticket_empty_dependencies() {
|
|
|
let t = Ticket::new("aaaaaa".to_string(), "No deps".to_string());
|
|
|
let output = format_ticket(&t);
|
|
|
assert!(
|
|
|
output.contains("dependencies"),
|
|
|
"dependencies key should always appear in frontmatter"
|
|
|
);
|
|
|
}
|
|
|
|
|
|
/// `format_ticket_json` produces valid, parseable JSON containing key fields.
|
|
|
///
|
|
|
/// Even though [`crate::ticket::Ticket::id`] is annotated with `#[serde(skip)]`
|
|
|
/// (to avoid storing it in files), the display layer re-injects `id` so that
|
|
|
/// CLI `--json` output is self-contained for machine consumers.
|
|
|
#[test]
|
|
|
fn format_ticket_json_is_valid_json() {
|
|
|
let t = sample_ticket();
|
|
|
let output = format_ticket_json(&t);
|
|
|
let parsed: serde_json::Value =
|
|
|
serde_json::from_str(&output).expect("output should be valid JSON");
|
|
|
assert_eq!(parsed["id"], "a3f9c2");
|
|
|
assert_eq!(parsed["title"], "Fix login bug");
|
|
|
assert_eq!(parsed["priority"], 8);
|
|
|
assert_eq!(parsed["status"], "in_progress");
|
|
|
assert_eq!(parsed["ticket_type"], "bug");
|
|
|
}
|
|
|
|
|
|
/// `format_list` includes a header row with all column names.
|
|
|
#[test]
|
|
|
fn format_list_shows_header() {
|
|
|
let output = format_list(&[]);
|
|
|
assert!(output.contains("ID"), "header should contain ID");
|
|
|
assert!(output.contains("PRI"), "header should contain PRI");
|
|
|
assert!(output.contains("TYPE"), "header should contain TYPE");
|
|
|
assert!(output.contains("STATUS"), "header should contain STATUS");
|
|
|
assert!(output.contains("TITLE"), "header should contain TITLE");
|
|
|
}
|
|
|
|
|
|
/// `format_list` includes every ticket's key fields.
|
|
|
#[test]
|
|
|
fn format_list_contains_all_tickets() {
|
|
|
let mut t1 = Ticket::new("id0001".to_string(), "First ticket".to_string());
|
|
|
t1.priority = 9;
|
|
|
let mut t2 = Ticket::new("id0002".to_string(), "Second ticket".to_string());
|
|
|
t2.priority = 3;
|
|
|
let tickets = vec![t1, t2];
|
|
|
|
|
|
let output = format_list(&tickets);
|
|
|
assert!(output.contains("id0001"), "should contain first ID");
|
|
|
assert!(
|
|
|
output.contains("First ticket"),
|
|
|
"should contain first title"
|
|
|
);
|
|
|
assert!(output.contains("id0002"), "should contain second ID");
|
|
|
assert!(
|
|
|
output.contains("Second ticket"),
|
|
|
"should contain second title"
|
|
|
);
|
|
|
}
|
|
|
|
|
|
/// `format_list` renders the correct status and type strings.
|
|
|
#[test]
|
|
|
fn format_list_renders_status_and_type_strings() {
|
|
|
let t = sample_ticket(); // status: in_progress, type: bug
|
|
|
let output = format_list(&[t]);
|
|
|
assert!(
|
|
|
output.contains("in_progress"),
|
|
|
"should render status string"
|
|
|
);
|
|
|
assert!(output.contains("bug"), "should render type string");
|
|
|
}
|
|
|
|
|
|
/// `format_list_json` produces a valid JSON array with one object per ticket,
|
|
|
/// with `id` explicitly included in each object for machine consumers.
|
|
|
#[test]
|
|
|
fn format_list_json_is_valid_json_array() {
|
|
|
let tickets = vec![
|
|
|
Ticket::new("id0001".to_string(), "First".to_string()),
|
|
|
Ticket::new("id0002".to_string(), "Second".to_string()),
|
|
|
];
|
|
|
let output = format_list_json(&tickets);
|
|
|
let parsed: serde_json::Value =
|
|
|
serde_json::from_str(&output).expect("output should be valid JSON");
|
|
|
assert!(parsed.is_array(), "output should be a JSON array");
|
|
|
assert_eq!(parsed.as_array().unwrap().len(), 2);
|
|
|
assert_eq!(parsed[0]["id"], "id0001");
|
|
|
assert_eq!(parsed[1]["id"], "id0002");
|
|
|
}
|
|
|
|
|
|
/// `format_list_json` returns an empty JSON array for an empty slice.
|
|
|
#[test]
|
|
|
fn format_list_json_empty_slice() {
|
|
|
let output = format_list_json(&[]);
|
|
|
let parsed: serde_json::Value =
|
|
|
serde_json::from_str(&output).expect("output should be valid JSON");
|
|
|
assert!(parsed.is_array());
|
|
|
assert!(parsed.as_array().unwrap().is_empty());
|
|
|
}
|
|
|
|
|
|
/// `format_migrate_report` includes counts for all three categories.
|
|
|
#[test]
|
|
|
fn format_migrate_report_contains_counts() {
|
|
|
let report = MigrateReport {
|
|
|
updated: 3,
|
|
|
already_current: 5,
|
|
|
skipped: 0,
|
|
|
errors: vec![("bad.json".to_string(), "parse error".to_string())],
|
|
|
};
|
|
|
let output = format_migrate_report(&report);
|
|
|
assert!(output.contains('3'), "should mention updated count");
|
|
|
assert!(output.contains('5'), "should mention already_current count");
|
|
|
assert!(output.contains("bad.json"), "should mention error filename");
|
|
|
assert!(
|
|
|
output.contains("parse error"),
|
|
|
"should mention error message"
|
|
|
);
|
|
|
}
|
|
|
|
|
|
/// `format_migrate_report` with no errors omits the Errors line.
|
|
|
#[test]
|
|
|
fn format_migrate_report_no_errors() {
|
|
|
let report = MigrateReport {
|
|
|
updated: 1,
|
|
|
already_current: 2,
|
|
|
skipped: 0,
|
|
|
errors: vec![],
|
|
|
};
|
|
|
let output = format_migrate_report(&report);
|
|
|
assert!(
|
|
|
!output.contains("Errors"),
|
|
|
"should not show Errors when none"
|
|
|
);
|
|
|
}
|
|
|
|
|
|
/// `format_migrate_report` shows a Skipped line when `skipped > 0`.
|
|
|
#[test]
|
|
|
fn format_migrate_report_shows_skipped_when_nonzero() {
|
|
|
let report = MigrateReport {
|
|
|
updated: 1,
|
|
|
already_current: 2,
|
|
|
skipped: 3,
|
|
|
errors: vec![],
|
|
|
};
|
|
|
let output = format_migrate_report(&report);
|
|
|
assert!(
|
|
|
output.contains("Skipped"),
|
|
|
"should show Skipped line when skipped > 0"
|
|
|
);
|
|
|
assert!(output.contains('3'), "should include skipped count");
|
|
|
}
|
|
|
|
|
|
/// `format_migrate_report` omits the Skipped line when `skipped == 0`.
|
|
|
#[test]
|
|
|
fn format_migrate_report_omits_skipped_when_zero() {
|
|
|
let report = MigrateReport {
|
|
|
updated: 1,
|
|
|
already_current: 2,
|
|
|
skipped: 0,
|
|
|
errors: vec![],
|
|
|
};
|
|
|
let output = format_migrate_report(&report);
|
|
|
assert!(
|
|
|
!output.contains("Skipped"),
|
|
|
"should not show Skipped when skipped == 0"
|
|
|
);
|
|
|
}
|
|
|
|
|
|
/// `format_migrate_report_json` includes a `skipped` key.
|
|
|
#[test]
|
|
|
fn format_migrate_report_json_includes_skipped() {
|
|
|
let report = MigrateReport {
|
|
|
updated: 1,
|
|
|
already_current: 2,
|
|
|
skipped: 4,
|
|
|
errors: vec![],
|
|
|
};
|
|
|
let output = format_migrate_report_json(&report);
|
|
|
let parsed: serde_json::Value =
|
|
|
serde_json::from_str(&output).expect("output should be valid JSON");
|
|
|
assert_eq!(parsed["skipped"], 4, "JSON should contain skipped count");
|
|
|
}
|
|
|
|
|
|
/// `format_migrate_report_json` produces valid JSON with the expected keys.
|
|
|
#[test]
|
|
|
fn format_migrate_report_json_is_valid() {
|
|
|
let report = MigrateReport {
|
|
|
updated: 2,
|
|
|
already_current: 4,
|
|
|
skipped: 0,
|
|
|
errors: vec![("err.json".to_string(), "bad".to_string())],
|
|
|
};
|
|
|
let output = format_migrate_report_json(&report);
|
|
|
let parsed: serde_json::Value =
|
|
|
serde_json::from_str(&output).expect("output should be valid JSON");
|
|
|
assert_eq!(parsed["updated"], 2);
|
|
|
assert_eq!(parsed["already_current"], 4);
|
|
|
assert!(parsed["errors"].is_array());
|
|
|
assert_eq!(parsed["errors"].as_array().unwrap().len(), 1);
|
|
|
assert_eq!(parsed["errors"][0]["filename"], "err.json");
|
|
|
assert_eq!(parsed["errors"][0]["message"], "bad");
|
|
|
}
|
|
|
|
|
|
// ── format_diff ───────────────────────────────────────────────────────────
|
|
|
|
|
|
/// `format_diff` returns `"(no changes)"` when old and new are identical.
|
|
|
#[test]
|
|
|
fn format_diff_no_changes() {
|
|
|
let t = sample_ticket();
|
|
|
let output = format_diff(&t, &t);
|
|
|
assert_eq!(output, "(no changes)");
|
|
|
}
|
|
|
|
|
|
/// `format_diff` shows `- old` and `+ new` lines only for changed fields.
|
|
|
#[test]
|
|
|
fn format_diff_shows_changed_fields_only() {
|
|
|
let old = sample_ticket(); // status: in_progress
|
|
|
let mut new = sample_ticket();
|
|
|
new.status = Status::Done;
|
|
|
let output = format_diff(&old, &new);
|
|
|
assert!(
|
|
|
output.contains("- status:"),
|
|
|
"should show old status line: {output}"
|
|
|
);
|
|
|
assert!(
|
|
|
output.contains("+ status:"),
|
|
|
"should show new status line: {output}"
|
|
|
);
|
|
|
assert!(
|
|
|
output.contains("in_progress"),
|
|
|
"should show old status value: {output}"
|
|
|
);
|
|
|
assert!(
|
|
|
output.contains("done"),
|
|
|
"should show new status value: {output}"
|
|
|
);
|
|
|
// Unchanged fields must not appear.
|
|
|
assert!(
|
|
|
!output.contains("title:"),
|
|
|
"unchanged title should not appear: {output}"
|
|
|
);
|
|
|
assert!(
|
|
|
!output.contains("priority:"),
|
|
|
"unchanged priority should not appear: {output}"
|
|
|
);
|
|
|
}
|
|
|
|
|
|
/// `format_diff` shows multiple changed fields when several differ.
|
|
|
#[test]
|
|
|
fn format_diff_multiple_changed_fields() {
|
|
|
let old = sample_ticket();
|
|
|
let mut new = sample_ticket();
|
|
|
new.status = Status::Done;
|
|
|
new.priority = 3;
|
|
|
let output = format_diff(&old, &new);
|
|
|
assert!(output.contains("status:"), "status diff should appear");
|
|
|
assert!(output.contains("priority:"), "priority diff should appear");
|
|
|
// title unchanged — must not appear.
|
|
|
assert!(
|
|
|
!output.contains("title:"),
|
|
|
"unchanged title should not appear: {output}"
|
|
|
);
|
|
|
}
|
|
|
|
|
|
/// `format_diff` renders dependency changes as comma-separated lists.
|
|
|
#[test]
|
|
|
fn format_diff_dependencies_comma_separated() {
|
|
|
let mut old = sample_ticket(); // deps: b7d41e, c9e823
|
|
|
let mut new = sample_ticket();
|
|
|
old.dependencies = vec!["aaaaaa".to_string()];
|
|
|
new.dependencies = vec!["bbbbbb".to_string(), "cccccc".to_string()];
|
|
|
let output = format_diff(&old, &new);
|
|
|
assert!(
|
|
|
output.contains("dependencies:"),
|
|
|
"dependencies diff should appear: {output}"
|
|
|
);
|
|
|
assert!(output.contains("aaaaaa"), "old dep should appear: {output}");
|
|
|
assert!(
|
|
|
output.contains("bbbbbb, cccccc"),
|
|
|
"new deps should be comma-separated: {output}"
|
|
|
);
|
|
|
}
|
|
|
}
|