fix: fix some displays and features
This commit is contained in:
211
crates/infrastructure/tests/skill_store.rs
Normal file
211
crates/infrastructure/tests/skill_store.rs
Normal file
@ -0,0 +1,211 @@
|
||||
//! L12 integration tests for [`FsSkillStore`] against a real temp directory and a
|
||||
//! real [`LocalFileSystem`]: md + `index.json` round-trip in **both** scopes,
|
||||
//! scope isolation (a `Project` skill never appears under `Global` and vice
|
||||
//! versa), upsert, delete, tolerant reads, and the on-disk layout.
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::ids::SkillId;
|
||||
use domain::markdown::MarkdownDoc;
|
||||
use domain::ports::{FileSystem, RemotePath, SkillStore, StoreError};
|
||||
use domain::project::ProjectPath;
|
||||
use domain::skill::{Skill, SkillScope};
|
||||
use infrastructure::{FsSkillStore, LocalFileSystem};
|
||||
use uuid::Uuid;
|
||||
|
||||
/// A unique scratch directory under the OS temp dir, cleaned up on drop. It plays
|
||||
/// **both** roles: its own path is the global app-data dir, and a `project/`
|
||||
/// child is the project root (so the two scopes resolve to disjoint subtrees).
|
||||
struct TempDir(PathBuf);
|
||||
impl TempDir {
|
||||
fn new() -> Self {
|
||||
let p = std::env::temp_dir().join(format!("idea-l12-skill-{}", Uuid::new_v4()));
|
||||
std::fs::create_dir_all(&p).unwrap();
|
||||
Self(p)
|
||||
}
|
||||
fn app_data_dir(&self) -> String {
|
||||
self.0.to_string_lossy().into_owned()
|
||||
}
|
||||
fn project_root(&self) -> ProjectPath {
|
||||
ProjectPath::new(self.0.join("project").to_string_lossy().into_owned()).unwrap()
|
||||
}
|
||||
fn child(&self, rel: &str) -> RemotePath {
|
||||
RemotePath::new(self.0.join(rel).to_string_lossy().into_owned())
|
||||
}
|
||||
}
|
||||
impl Drop for TempDir {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
fn store(tmp: &TempDir) -> FsSkillStore {
|
||||
let fs: Arc<dyn FileSystem> = Arc::new(LocalFileSystem::new());
|
||||
FsSkillStore::new(fs, tmp.app_data_dir())
|
||||
}
|
||||
|
||||
fn sid(n: u128) -> SkillId {
|
||||
SkillId::from_uuid(Uuid::from_u128(n))
|
||||
}
|
||||
|
||||
fn skill(id: SkillId, name: &str, content: &str, scope: SkillScope) -> Skill {
|
||||
Skill::new(id, name, MarkdownDoc::new(content), scope).unwrap()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn missing_index_lists_empty_in_both_scopes() {
|
||||
let tmp = TempDir::new();
|
||||
let s = store(&tmp);
|
||||
let root = tmp.project_root();
|
||||
assert!(s.list(SkillScope::Global, &root).await.unwrap().is_empty());
|
||||
assert!(s.list(SkillScope::Project, &root).await.unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn global_roundtrip_save_get_list() {
|
||||
let tmp = TempDir::new();
|
||||
let s = store(&tmp);
|
||||
let root = tmp.project_root();
|
||||
|
||||
let sk = skill(sid(1), "refactor", "# Refactor workflow", SkillScope::Global);
|
||||
s.save(&sk, &root).await.unwrap();
|
||||
|
||||
assert_eq!(s.get(SkillScope::Global, &root, sid(1)).await.unwrap(), sk);
|
||||
assert_eq!(
|
||||
s.list(SkillScope::Global, &root).await.unwrap(),
|
||||
vec![sk.clone()]
|
||||
);
|
||||
|
||||
// The Markdown landed under the global skills dir.
|
||||
let fs = LocalFileSystem::new();
|
||||
let bytes = fs
|
||||
.read(&tmp.child(&format!("skills/md/{}.md", sid(1))))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(String::from_utf8(bytes).unwrap(), sk.content_md.as_str());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn project_roundtrip_lands_under_ideai() {
|
||||
let tmp = TempDir::new();
|
||||
let s = store(&tmp);
|
||||
let root = tmp.project_root();
|
||||
|
||||
let sk = skill(sid(1), "review", "# Review workflow", SkillScope::Project);
|
||||
s.save(&sk, &root).await.unwrap();
|
||||
|
||||
assert_eq!(s.get(SkillScope::Project, &root, sid(1)).await.unwrap(), sk);
|
||||
|
||||
let fs = LocalFileSystem::new();
|
||||
let bytes = fs
|
||||
.read(&tmp.child(&format!("project/.ideai/skills/md/{}.md", sid(1))))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(String::from_utf8(bytes).unwrap(), sk.content_md.as_str());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn scopes_are_isolated() {
|
||||
let tmp = TempDir::new();
|
||||
let s = store(&tmp);
|
||||
let root = tmp.project_root();
|
||||
|
||||
s.save(&skill(sid(1), "g", "global body", SkillScope::Global), &root)
|
||||
.await
|
||||
.unwrap();
|
||||
s.save(
|
||||
&skill(sid(2), "p", "project body", SkillScope::Project),
|
||||
&root,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// A global skill never surfaces in the project scope and vice-versa.
|
||||
let globals = s.list(SkillScope::Global, &root).await.unwrap();
|
||||
let projects = s.list(SkillScope::Project, &root).await.unwrap();
|
||||
assert_eq!(globals.len(), 1);
|
||||
assert_eq!(projects.len(), 1);
|
||||
assert_eq!(globals[0].id, sid(1));
|
||||
assert_eq!(projects[0].id, sid(2));
|
||||
|
||||
assert!(matches!(
|
||||
s.get(SkillScope::Global, &root, sid(2)).await.unwrap_err(),
|
||||
StoreError::NotFound
|
||||
));
|
||||
assert!(matches!(
|
||||
s.get(SkillScope::Project, &root, sid(1)).await.unwrap_err(),
|
||||
StoreError::NotFound
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn save_upserts_content() {
|
||||
let tmp = TempDir::new();
|
||||
let s = store(&tmp);
|
||||
let root = tmp.project_root();
|
||||
|
||||
s.save(&skill(sid(1), "k", "v1", SkillScope::Global), &root)
|
||||
.await
|
||||
.unwrap();
|
||||
s.save(&skill(sid(1), "k", "v2", SkillScope::Global), &root)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
s.get(SkillScope::Global, &root, sid(1))
|
||||
.await
|
||||
.unwrap()
|
||||
.content_md
|
||||
.as_str(),
|
||||
"v2"
|
||||
);
|
||||
assert_eq!(
|
||||
s.list(SkillScope::Global, &root).await.unwrap().len(),
|
||||
1,
|
||||
"upsert, not append"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn delete_removes_from_index_and_is_not_found_twice() {
|
||||
let tmp = TempDir::new();
|
||||
let s = store(&tmp);
|
||||
let root = tmp.project_root();
|
||||
s.save(&skill(sid(1), "k", "x", SkillScope::Project), &root)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
s.delete(SkillScope::Project, &root, sid(1)).await.unwrap();
|
||||
assert!(s.list(SkillScope::Project, &root).await.unwrap().is_empty());
|
||||
assert!(matches!(
|
||||
s.delete(SkillScope::Project, &root, sid(1))
|
||||
.await
|
||||
.unwrap_err(),
|
||||
StoreError::NotFound
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn index_is_camelcase_with_content_hash() {
|
||||
let tmp = TempDir::new();
|
||||
let s = store(&tmp);
|
||||
let root = tmp.project_root();
|
||||
s.save(&skill(sid(1), "refactor", "hello", SkillScope::Global), &root)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let fs = LocalFileSystem::new();
|
||||
let bytes = fs.read(&tmp.child("skills/index.json")).await.unwrap();
|
||||
let json: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
|
||||
let entry = &json.get("skills").unwrap().as_array().unwrap()[0];
|
||||
assert_eq!(entry.get("name").and_then(|v| v.as_str()), Some("refactor"));
|
||||
assert!(
|
||||
entry.get("contentHash").is_some(),
|
||||
"camelCase contentHash present"
|
||||
);
|
||||
assert!(
|
||||
entry.get("content_hash").is_none(),
|
||||
"no snake_case leak"
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user