f7a720204a
- 移除 GovAI, nomifun-tauri, 算力盒子 的 submodule 引用 - 添加所有子项目的完整源代码 - 保留原始 .git 为 .git.bak 备份
325 lines
11 KiB
Rust
325 lines
11 KiB
Rust
//! Integration tests for FileStateCache (TC-5.2 series from test-plan.md).
|
|
//!
|
|
//! Black-box tests targeting the public API of FileStateCache without
|
|
//! depending on internal implementation details.
|
|
|
|
use std::path::{Path, PathBuf};
|
|
use std::sync::{Arc, RwLock};
|
|
|
|
use nomi_config::file_cache::FileCacheConfig;
|
|
use nomi_tools::file_cache::{FileStateCache, file_mtime_ms, update_cache_after_write};
|
|
use nomi_types::file_state::FileState;
|
|
|
|
fn default_config() -> FileCacheConfig {
|
|
FileCacheConfig {
|
|
max_entries: 100,
|
|
max_size_bytes: 25 * 1024 * 1024,
|
|
enabled: true,
|
|
}
|
|
}
|
|
|
|
fn make_state(content: &str, mtime_ms: u64) -> FileState {
|
|
FileState {
|
|
content: content.to_string(),
|
|
mtime_ms,
|
|
offset: None,
|
|
limit: None,
|
|
}
|
|
}
|
|
|
|
/// TC-5.2-01: Insert and retrieve a file state entry.
|
|
#[test]
|
|
fn tc_5_2_01_insert_and_retrieve() {
|
|
let mut cache = FileStateCache::new(&default_config());
|
|
|
|
let path = PathBuf::from("/home/user/project/main.rs");
|
|
let state = make_state(" 1\tfn main() {}", 1_700_000_000_000);
|
|
|
|
cache.insert(path.clone(), state);
|
|
|
|
let retrieved = cache.get(&path).expect("entry should exist");
|
|
assert_eq!(retrieved.content, " 1\tfn main() {}");
|
|
assert_eq!(retrieved.mtime_ms, 1_700_000_000_000);
|
|
assert!(retrieved.offset.is_none());
|
|
assert!(retrieved.limit.is_none());
|
|
}
|
|
|
|
/// TC-5.2-02: Getting a non-existent key returns None.
|
|
#[test]
|
|
fn tc_5_2_02_nonexistent_key() {
|
|
let mut cache = FileStateCache::new(&default_config());
|
|
assert!(cache.get(Path::new("/no/such/file.rs")).is_none());
|
|
}
|
|
|
|
/// TC-5.2-03: LRU eviction when count exceeds max_entries.
|
|
#[test]
|
|
fn tc_5_2_03_lru_count_eviction() {
|
|
let config = FileCacheConfig {
|
|
max_entries: 3,
|
|
max_size_bytes: 10_000_000,
|
|
enabled: true,
|
|
};
|
|
let mut cache = FileStateCache::new(&config);
|
|
|
|
cache.insert(PathBuf::from("/f1"), make_state("1", 1));
|
|
cache.insert(PathBuf::from("/f2"), make_state("2", 2));
|
|
cache.insert(PathBuf::from("/f3"), make_state("3", 3));
|
|
|
|
// 4th insert should evict /f1 (the LRU)
|
|
cache.insert(PathBuf::from("/f4"), make_state("4", 4));
|
|
|
|
assert!(
|
|
cache.get(Path::new("/f1")).is_none(),
|
|
"/f1 should be evicted"
|
|
);
|
|
assert!(cache.get(Path::new("/f2")).is_some());
|
|
assert!(cache.get(Path::new("/f3")).is_some());
|
|
assert!(cache.get(Path::new("/f4")).is_some());
|
|
assert_eq!(cache.len(), 3);
|
|
}
|
|
|
|
/// TC-5.2-04: Path normalization ensures equivalent paths hit the same slot.
|
|
#[test]
|
|
fn tc_5_2_04_path_normalization() {
|
|
let mut cache = FileStateCache::new(&default_config());
|
|
|
|
// Insert with redundant `..` in path
|
|
cache.insert(
|
|
PathBuf::from("/project/src/../lib/file.rs"),
|
|
make_state("content", 100),
|
|
);
|
|
|
|
// Retrieve using canonical-style path
|
|
let got = cache
|
|
.get(Path::new("/project/lib/file.rs"))
|
|
.expect("normalized path should hit cache");
|
|
assert_eq!(got.content, "content");
|
|
|
|
// Only one entry in cache
|
|
assert_eq!(cache.len(), 1);
|
|
}
|
|
|
|
/// TC-5.2-05: clear() removes all entries and resets size accounting.
|
|
#[test]
|
|
fn tc_5_2_05_clear() {
|
|
let mut cache = FileStateCache::new(&default_config());
|
|
|
|
cache.insert(PathBuf::from("/a"), make_state("aaa", 1));
|
|
cache.insert(PathBuf::from("/b"), make_state("bbb", 2));
|
|
cache.insert(PathBuf::from("/c"), make_state("ccc", 3));
|
|
assert_eq!(cache.len(), 3);
|
|
assert!(cache.current_size_bytes() > 0);
|
|
|
|
cache.clear();
|
|
assert_eq!(cache.len(), 0);
|
|
assert!(cache.is_empty());
|
|
assert_eq!(cache.current_size_bytes(), 0);
|
|
assert!(cache.get(Path::new("/a")).is_none());
|
|
assert!(cache.get(Path::new("/b")).is_none());
|
|
assert!(cache.get(Path::new("/c")).is_none());
|
|
}
|
|
|
|
/// TC-5.2-06: remove() deletes a specific entry and returns it.
|
|
#[test]
|
|
fn tc_5_2_06_remove() {
|
|
let mut cache = FileStateCache::new(&default_config());
|
|
|
|
cache.insert(PathBuf::from("/target"), make_state("data", 1));
|
|
cache.insert(PathBuf::from("/keep"), make_state("keep", 2));
|
|
|
|
let removed = cache.remove(Path::new("/target"));
|
|
assert!(removed.is_some());
|
|
assert_eq!(removed.unwrap().content, "data");
|
|
|
|
assert!(cache.get(Path::new("/target")).is_none());
|
|
assert!(cache.get(Path::new("/keep")).is_some());
|
|
assert_eq!(cache.len(), 1);
|
|
}
|
|
|
|
/// TC-5.2-07: Byte-size limit triggers LRU eviction of old entries.
|
|
#[test]
|
|
fn tc_5_2_07_byte_size_eviction() {
|
|
let config = FileCacheConfig {
|
|
max_entries: 100,
|
|
max_size_bytes: 15, // tight byte budget
|
|
enabled: true,
|
|
};
|
|
let mut cache = FileStateCache::new(&config);
|
|
|
|
// Insert two 6-byte entries: total = 12, within budget
|
|
cache.insert(PathBuf::from("/a"), make_state("aaaaaa", 1)); // 6 bytes
|
|
cache.insert(PathBuf::from("/b"), make_state("bbbbbb", 2)); // 6 bytes
|
|
assert_eq!(cache.len(), 2);
|
|
assert_eq!(cache.current_size_bytes(), 12);
|
|
|
|
// Insert 6-byte entry: 12 + 6 = 18 > 15 -> evicts /a (LRU), total = 12
|
|
cache.insert(PathBuf::from("/c"), make_state("cccccc", 3));
|
|
assert!(cache.get(Path::new("/a")).is_none(), "/a should be evicted");
|
|
assert!(cache.get(Path::new("/b")).is_some());
|
|
assert!(cache.get(Path::new("/c")).is_some());
|
|
assert!(cache.current_size_bytes() <= 15);
|
|
}
|
|
|
|
/// TC-5.2-08: Inserting the same path twice updates (overwrites) the entry.
|
|
#[test]
|
|
fn tc_5_2_08_overwrite_update() {
|
|
let mut cache = FileStateCache::new(&default_config());
|
|
|
|
cache.insert(PathBuf::from("/file"), make_state("version1", 100));
|
|
cache.insert(PathBuf::from("/file"), make_state("version2-updated", 200));
|
|
|
|
let got = cache.get(Path::new("/file")).expect("entry should exist");
|
|
assert_eq!(got.content, "version2-updated");
|
|
assert_eq!(got.mtime_ms, 200);
|
|
assert_eq!(cache.len(), 1);
|
|
assert_eq!(cache.current_size_bytes(), "version2-updated".len());
|
|
}
|
|
|
|
/// Supplementary: LRU promotion via get() prevents eviction of accessed entries.
|
|
#[test]
|
|
fn lru_promotion_via_get() {
|
|
let config = FileCacheConfig {
|
|
max_entries: 3,
|
|
max_size_bytes: 10_000_000,
|
|
enabled: true,
|
|
};
|
|
let mut cache = FileStateCache::new(&config);
|
|
|
|
cache.insert(PathBuf::from("/oldest"), make_state("o", 1));
|
|
cache.insert(PathBuf::from("/middle"), make_state("m", 2));
|
|
cache.insert(PathBuf::from("/newest"), make_state("n", 3));
|
|
|
|
// Access /oldest to promote it; /middle becomes the new LRU
|
|
cache.get(Path::new("/oldest"));
|
|
|
|
// Insert /extra -> evicts /middle (now the LRU)
|
|
cache.insert(PathBuf::from("/extra"), make_state("e", 4));
|
|
|
|
assert!(
|
|
cache.get(Path::new("/oldest")).is_some(),
|
|
"/oldest was promoted and should survive"
|
|
);
|
|
assert!(
|
|
cache.get(Path::new("/middle")).is_none(),
|
|
"/middle should be evicted as the new LRU"
|
|
);
|
|
}
|
|
|
|
/// Supplementary: remove on a non-existent key returns None without panic.
|
|
#[test]
|
|
fn remove_nonexistent_returns_none() {
|
|
let mut cache = FileStateCache::new(&default_config());
|
|
assert!(cache.remove(Path::new("/ghost")).is_none());
|
|
}
|
|
|
|
/// Supplementary: partial read state (offset + limit) is preserved.
|
|
#[test]
|
|
fn partial_read_state_round_trip() {
|
|
let mut cache = FileStateCache::new(&default_config());
|
|
|
|
let state = FileState {
|
|
content: "partial".to_string(),
|
|
mtime_ms: 999,
|
|
offset: Some(10),
|
|
limit: Some(20),
|
|
};
|
|
cache.insert(PathBuf::from("/partial"), state);
|
|
|
|
let got = cache.get(Path::new("/partial")).unwrap();
|
|
assert_eq!(got.offset, Some(10));
|
|
assert_eq!(got.limit, Some(20));
|
|
}
|
|
|
|
// ==========================================================================
|
|
// TC-5.5: update_cache_after_write helper and config integration
|
|
// ==========================================================================
|
|
|
|
/// TC-5.5-01: Cache created with custom max_entries has correct capacity.
|
|
#[test]
|
|
fn tc_5_5_01_custom_capacity() {
|
|
let config = FileCacheConfig {
|
|
max_entries: 50,
|
|
max_size_bytes: 25 * 1024 * 1024,
|
|
enabled: true,
|
|
};
|
|
let mut cache = FileStateCache::new(&config);
|
|
|
|
// Insert 50 entries: all should fit.
|
|
for i in 0..50 {
|
|
cache.insert(PathBuf::from(format!("/f{}", i)), make_state("x", i));
|
|
}
|
|
assert_eq!(cache.len(), 50);
|
|
|
|
// 51st entry evicts the LRU.
|
|
cache.insert(PathBuf::from("/f50"), make_state("x", 50));
|
|
assert_eq!(cache.len(), 50);
|
|
assert!(
|
|
cache.get(Path::new("/f0")).is_none(),
|
|
"/f0 should be evicted at capacity 50"
|
|
);
|
|
}
|
|
|
|
/// update_cache_after_write stores line-numbered content with correct mtime.
|
|
#[test]
|
|
fn update_cache_after_write_stores_numbered_content() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let file = dir.path().join("helper_test.txt");
|
|
let content = "line one\nline two\nline three";
|
|
std::fs::write(&file, content).unwrap();
|
|
|
|
let cache_arc = Arc::new(RwLock::new(FileStateCache::new(&default_config())));
|
|
update_cache_after_write(&cache_arc, &file, content);
|
|
|
|
let mut cache = cache_arc.write().unwrap();
|
|
let cached = cache.get(&file).expect("entry should exist after update");
|
|
|
|
// Content should be line-numbered.
|
|
assert!(cached.content.contains(" 1\tline one"));
|
|
assert!(cached.content.contains(" 2\tline two"));
|
|
assert!(cached.content.contains(" 3\tline three"));
|
|
|
|
// Mtime should match disk.
|
|
let disk_mtime = file_mtime_ms(&file).unwrap();
|
|
assert_eq!(cached.mtime_ms, disk_mtime);
|
|
|
|
// Offset and limit should be None (full file).
|
|
assert!(cached.offset.is_none());
|
|
assert!(cached.limit.is_none());
|
|
}
|
|
|
|
/// update_cache_after_write handles empty content.
|
|
#[test]
|
|
fn update_cache_after_write_empty_content() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let file = dir.path().join("empty.txt");
|
|
std::fs::write(&file, "").unwrap();
|
|
|
|
let cache_arc = Arc::new(RwLock::new(FileStateCache::new(&default_config())));
|
|
update_cache_after_write(&cache_arc, &file, "");
|
|
|
|
let mut cache = cache_arc.write().unwrap();
|
|
let cached = cache.get(&file).expect("entry should exist");
|
|
assert_eq!(cached.content, "");
|
|
}
|
|
|
|
/// update_cache_after_write overwrites previous entry.
|
|
#[test]
|
|
fn update_cache_after_write_overwrites_previous() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let file = dir.path().join("overwrite.txt");
|
|
|
|
std::fs::write(&file, "v1").unwrap();
|
|
let cache_arc = Arc::new(RwLock::new(FileStateCache::new(&default_config())));
|
|
update_cache_after_write(&cache_arc, &file, "v1");
|
|
|
|
// Brief delay for mtime change.
|
|
std::thread::sleep(std::time::Duration::from_millis(50));
|
|
std::fs::write(&file, "v2 updated").unwrap();
|
|
update_cache_after_write(&cache_arc, &file, "v2 updated");
|
|
|
|
let mut cache = cache_arc.write().unwrap();
|
|
let cached = cache.get(&file).unwrap();
|
|
assert!(cached.content.contains("v2 updated"));
|
|
assert_eq!(cached.mtime_ms, file_mtime_ms(&file).unwrap());
|
|
}
|