From 7976886bc5e329ad92388ee666d45a45e792da9a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9E=97=20=E9=A7=BF=E7=94=AB=20=28Shunsuke=20Hayashi=29?= Date: Fri, 10 Apr 2026 10:18:41 +0900 Subject: [PATCH] test(core): add proptest property-based tests for lock and store MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds proptest to dev-dependencies and 6 property-based tests: - lock: acquire→release always clears, two tasks can't lock same file, wrong owner renew always fails - store: CAS rejects stale version, upsert is idempotent, event replay is deterministic 945 tests all GREEN. Closes #100 Co-Authored-By: Claude Opus 4.6 (1M context) --- crates/miyabi-core/Cargo.toml | 1 + crates/miyabi-core/src/lock.rs | 88 +++++++++++++++++++++++++++++++++ crates/miyabi-core/src/store.rs | 65 ++++++++++++++++++++++++ 3 files changed, 154 insertions(+) diff --git a/crates/miyabi-core/Cargo.toml b/crates/miyabi-core/Cargo.toml index 670077f..d26e26c 100644 --- a/crates/miyabi-core/Cargo.toml +++ b/crates/miyabi-core/Cargo.toml @@ -32,3 +32,4 @@ toml = "0.8" [dev-dependencies] tempfile = "3" +proptest = "1" diff --git a/crates/miyabi-core/src/lock.rs b/crates/miyabi-core/src/lock.rs index 8ed58fd..05a86f3 100644 --- a/crates/miyabi-core/src/lock.rs +++ b/crates/miyabi-core/src/lock.rs @@ -495,3 +495,91 @@ mod tests { assert!(sweep.active.is_empty()); } } + +#[cfg(test)] +mod proptest_tests { + use super::*; + use crate::store::{CompletionMode, EventStore, ExecutionTask, SnapshotStore}; + use proptest::prelude::*; + use tempfile::TempDir; + + fn arb_file_name() -> impl Strategy { + prop::string::string_regex("[a-z]{1,8}/[a-z]{1,8}\\.rs") + .unwrap() + .prop_filter("non-empty", |s| !s.is_empty()) + } + + fn arb_task_id() -> impl Strategy { + prop::string::string_regex("task-[a-z]{1,4}") + .unwrap() + .prop_filter("non-empty", |s| !s.is_empty()) + } + + fn setup() -> (TempDir, SnapshotStore, FileLockManager) { + let tmp = TempDir::new().unwrap(); + let event_store = EventStore::new(tmp.path().join("events.jsonl")); + let snapshot_store = SnapshotStore::new( + tmp.path().join("tasks.snapshot.json"), + tmp.path().join(".tasks.lock"), + ); + let manager = FileLockManager::new( + event_store, + snapshot_store.clone(), + LeaseConfig { + lease_duration_sec: 300, + heartbeat_interval_sec: 60, + stale_after_missed_heartbeats: 2, + }, + ); + (tmp, snapshot_store, manager) + } + + fn seed(snapshot_store: &SnapshotStore, id: &str) { + let mut snapshot = snapshot_store.load().unwrap(); + let mut task = ExecutionTask::new(id, format!("Task {id}")); + task.completion_mode = CompletionMode::GithubPr; + snapshot.upsert_task(task); + let version = snapshot.version; + snapshot_store.save(&snapshot, version).unwrap(); + } + + proptest! { + #[test] + fn acquire_then_release_always_clears( + file in arb_file_name() + ) { + let (_tmp, ss, mgr) = setup(); + seed(&ss, "task-a"); + mgr.acquire_lock("task-a", "agent", "node", &[file.clone()]).unwrap(); + let c = mgr.has_conflict(&[file.clone()]).unwrap(); + prop_assert!(c.conflicting); + mgr.release_lock("task-a").unwrap(); + let c = mgr.has_conflict(&[file]).unwrap(); + prop_assert!(!c.conflicting); + } + + #[test] + fn two_tasks_cannot_lock_same_file( + file in arb_file_name() + ) { + let (_tmp, ss, mgr) = setup(); + seed(&ss, "task-a"); + seed(&ss, "task-b"); + mgr.acquire_lock("task-a", "agent", "node", &[file.clone()]).unwrap(); + let result = mgr.acquire_lock("task-b", "agent", "node", &[file]); + prop_assert!(result.is_err()); + } + + #[test] + fn renew_by_wrong_owner_always_fails( + task_id in arb_task_id(), + file in arb_file_name() + ) { + let (_tmp, ss, mgr) = setup(); + seed(&ss, &task_id); + mgr.acquire_lock(&task_id, "owner", "node", &[file]).unwrap(); + let result = mgr.renew_lease(&task_id, "intruder", "other"); + prop_assert!(result.is_err()); + } + } +} diff --git a/crates/miyabi-core/src/store.rs b/crates/miyabi-core/src/store.rs index ec611f8..d0a97c5 100644 --- a/crates/miyabi-core/src/store.rs +++ b/crates/miyabi-core/src/store.rs @@ -1070,3 +1070,68 @@ mod tests { assert_eq!(result_zero, base); } } + +#[cfg(test)] +mod proptest_tests { + use super::*; + use proptest::prelude::*; + use tempfile::TempDir; + + fn sample_task(id: &str) -> ExecutionTask { + ExecutionTask::new(id, format!("Task {id}")) + } + + proptest! { + #[test] + fn cas_rejects_stale_version(version in 1u64..100) { + let tmp = TempDir::new().unwrap(); + let store = SnapshotStore::new( + tmp.path().join("snap.json"), + tmp.path().join(".lock"), + ); + let mut snap = TasksSnapshot::default(); + snap.upsert_task(sample_task("t")); + store.save(&snap, 0).unwrap(); + + // Try saving with wrong version (0, already bumped to 1) + let result = store.save(&snap, 0); + prop_assert!(result.is_err()); + } + + #[test] + fn upsert_is_idempotent(n in 1usize..10) { + let tmp = TempDir::new().unwrap(); + let store = SnapshotStore::new( + tmp.path().join("snap.json"), + tmp.path().join(".lock"), + ); + let mut snap = TasksSnapshot::default(); + for _ in 0..n { + snap.upsert_task(sample_task("same-id")); + } + prop_assert_eq!(snap.tasks.len(), 1); + } + + #[test] + fn event_replay_is_deterministic(count in 1usize..20) { + let tmp = TempDir::new().unwrap(); + let es = EventStore::new(tmp.path().join("events.jsonl")); + for i in 0..count { + es.append(&TaskEvent { + id: format!("e-{i}"), + ts: Utc::now(), + event_type: TaskEventType::GatePassed, + task_id: "task-a".into(), + agent: "t".into(), + node: "t".into(), + payload: serde_json::json!({}), + version: i as u64, + }).unwrap(); + } + let r1 = es.replay(None).unwrap(); + let r2 = es.replay(None).unwrap(); + prop_assert_eq!(r1.len(), r2.len()); + prop_assert_eq!(r1.len(), count); + } + } +}