Merge pull request #101 from Miyabi-G-K/feature/issue-100-proptest

test(core): proptest property-based tests for lock/store
This commit is contained in:
林 駿甫 (Shunsuke Hayashi) 2026-04-10 10:19:05 +09:00 committed by GitHub
commit 92c551f1b7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 154 additions and 0 deletions

View file

@ -32,3 +32,4 @@ toml = "0.8"
[dev-dependencies]
tempfile = "3"
proptest = "1"

View file

@ -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<Value = String> {
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<Value = String> {
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());
}
}
}

View file

@ -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);
}
}
}