feat(orchestrator): create_skill action + wire per-project watchers (§14.3)

- domain: OrchestratorCommand::CreateSkill with optional scope field
  (defaults to Project, case-insensitive, UnknownScope on bad value)
- application: dispatch CreateSkill through the CreateSkill use case
- app-tauri: build OrchestratorService and start/stop per-project request
  watchers on open/create/close_project (ensure/stop_orchestrator_watch)
- tests: domain validation, service dispatch, infra watcher e2e,
  app-tauri wiring lifecycle (idempotent, isolated, stop)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-07 13:06:41 +02:00
parent b9fd2fb925
commit d11eaaa8c0
13 changed files with 619 additions and 52 deletions

View File

@ -0,0 +1,100 @@
//! Integration test for the orchestrator wiring in the composition root
//! (ARCHITECTURE §14.3).
//!
//! These tests prove that [`AppState`] actually *starts and stops* per-project
//! orchestrator watchers — the gap that previously left the whole §14.3 feature
//! dormant (the `OrchestratorService`/watcher existed but were never constructed
//! at runtime). The per-file request→dispatch→response behaviour is covered by
//! the infrastructure watcher tests; here we assert the lifecycle the open/close
//! commands rely on: registration is idempotent, projects are isolated, and
//! stopping unregisters.
use std::path::PathBuf;
use app_tauri_lib::state::AppState;
use domain::ports::IdGenerator;
use domain::project::{Project, ProjectPath};
use domain::remote::RemoteRef;
use domain::ProjectId;
use infrastructure::UuidGenerator;
/// A unique, absolute temp path (never written to at build time — the stores are
/// lazy — so it need not exist).
fn temp_path(tag: &str) -> PathBuf {
let ids = UuidGenerator::new();
std::env::temp_dir().join(format!("idea-orch-test-{tag}-{}", ids.new_uuid()))
}
/// Builds a domain [`Project`] rooted at a fresh temp path.
fn make_project() -> Project {
let ids = UuidGenerator::new();
let root = temp_path("root");
Project::new(
ProjectId::from_uuid(ids.new_uuid()),
"demo",
ProjectPath::new(root.to_string_lossy().into_owned()).unwrap(),
RemoteRef::local(),
1_700_000_000_000,
)
.unwrap()
}
fn watcher_count(state: &AppState) -> usize {
state.orchestrator_watchers.lock().unwrap().len()
}
fn has_watcher(state: &AppState, id: &ProjectId) -> bool {
state.orchestrator_watchers.lock().unwrap().contains_key(id)
}
#[tokio::test]
async fn ensure_watch_registers_a_watcher_and_is_idempotent() {
let state = AppState::build(temp_path("appdata"));
let project = make_project();
assert_eq!(watcher_count(&state), 0, "no watcher before open");
state.ensure_orchestrator_watch(&project);
assert!(has_watcher(&state, &project.id));
assert_eq!(watcher_count(&state), 1);
// Opening the same project again must not spawn a second watcher.
state.ensure_orchestrator_watch(&project);
assert_eq!(watcher_count(&state), 1, "ensure is idempotent per project");
}
#[tokio::test]
async fn stop_watch_unregisters_the_watcher() {
let state = AppState::build(temp_path("appdata"));
let project = make_project();
state.ensure_orchestrator_watch(&project);
assert!(has_watcher(&state, &project.id));
state.stop_orchestrator_watch(&project.id);
assert!(!has_watcher(&state, &project.id), "watcher removed on close");
assert_eq!(watcher_count(&state), 0);
// Stopping an unknown project is a no-op (does not panic).
state.stop_orchestrator_watch(&project.id);
}
#[tokio::test]
async fn watchers_are_isolated_per_project() {
let state = AppState::build(temp_path("appdata"));
let a = make_project();
let b = make_project();
state.ensure_orchestrator_watch(&a);
state.ensure_orchestrator_watch(&b);
assert_eq!(watcher_count(&state), 2);
assert!(has_watcher(&state, &a.id));
assert!(has_watcher(&state, &b.id));
// Closing one leaves the other running.
state.stop_orchestrator_watch(&a.id);
assert!(!has_watcher(&state, &a.id));
assert!(has_watcher(&state, &b.id));
assert_eq!(watcher_count(&state), 1);
}