Update: 将子项目从 submodule 转为完整内容
- 移除 GovAI, nomifun-tauri, 算力盒子 的 submodule 引用 - 添加所有子项目的完整源代码 - 保留原始 .git 为 .git.bak 备份
This commit is contained in:
@@ -0,0 +1,111 @@
|
||||
//! Spec §9.B wiring test: deleting a terminal session must dispatch the
|
||||
//! `OnTerminalDelete` hook into `RequirementService::clear_owner_for_session`,
|
||||
//! clearing the dual-domain `owner_session_id`/`owner_kind` (no FK to cascade)
|
||||
//! of every requirement that terminal owned and re-pending any `in_progress`
|
||||
//! one. This exercises the real wiring (`TerminalService::with_delete_hook` +
|
||||
//! dispatch in `delete()`), not just the service method in isolation.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use nomifun_api_types::{AutoWorkTargetKind, CreateRequirementRequest, RequirementStatus};
|
||||
use nomifun_common::OnTerminalDelete;
|
||||
use nomifun_db::{
|
||||
CreateTerminalParams, IRequirementRepository, ITerminalRepository, SqliteRequirementRepository,
|
||||
SqliteTerminalRepository, init_database_memory,
|
||||
};
|
||||
use nomifun_realtime::EventBroadcaster;
|
||||
use nomifun_requirement::{RequirementEventEmitter, RequirementService};
|
||||
use nomifun_terminal::{TerminalEventEmitter, TerminalService};
|
||||
|
||||
#[derive(Default)]
|
||||
struct NoopBroadcaster;
|
||||
impl EventBroadcaster for NoopBroadcaster {
|
||||
fn broadcast(&self, _event: nomifun_api_types::WebSocketMessage<serde_json::Value>) {}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn deleting_terminal_clears_requirement_owner_via_hook() {
|
||||
let db = init_database_memory().await.unwrap();
|
||||
let pool = db.pool().clone();
|
||||
sqlx::query(
|
||||
"INSERT INTO users (id, username, password_hash, created_at, updated_at) \
|
||||
VALUES ('user_1', 'tester', 'h', 0, 0)",
|
||||
)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let term_repo: Arc<dyn ITerminalRepository> = Arc::new(SqliteTerminalRepository::new(pool.clone()));
|
||||
let req_repo: Arc<dyn IRequirementRepository> = Arc::new(SqliteRequirementRepository::new(pool.clone()));
|
||||
|
||||
// Requirement service is the hook target.
|
||||
let req_service = Arc::new(RequirementService::new(
|
||||
req_repo,
|
||||
RequirementEventEmitter::new(Arc::new(NoopBroadcaster)),
|
||||
));
|
||||
|
||||
// Terminal service wired exactly as `nomifun-app::build_terminal_state` does:
|
||||
// register the requirement service as an `OnTerminalDelete` hook.
|
||||
let term_service = TerminalService::new(
|
||||
term_repo.clone(),
|
||||
TerminalEventEmitter::new(Arc::new(NoopBroadcaster)),
|
||||
std::env::temp_dir(),
|
||||
);
|
||||
term_service.with_delete_hook(req_service.clone() as Arc<dyn OnTerminalDelete>);
|
||||
|
||||
// Persist a terminal row (no live PTY needed — delete tolerates that). The
|
||||
// id is minted by SQLite and returned on the row.
|
||||
let term = term_repo
|
||||
.create(&CreateTerminalParams {
|
||||
name: "Term One".into(),
|
||||
cwd: std::env::temp_dir().to_string_lossy().into_owned(),
|
||||
command: "claude".into(),
|
||||
args: "[]".into(),
|
||||
env: None,
|
||||
backend: Some("claude".into()),
|
||||
mode: None,
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
user_id: "user_1".into(),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
let term_id = term.id;
|
||||
|
||||
// Create a requirement and let the terminal claim it (owner=term_1, in_progress).
|
||||
let r = req_service
|
||||
.create(CreateRequirementRequest {
|
||||
title: "T".into(),
|
||||
content: String::new(),
|
||||
tag: "auto".into(),
|
||||
order_key: Some("1".into()),
|
||||
status: None,
|
||||
created_by: None,
|
||||
attachments: vec![],
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
let claimed = req_service
|
||||
.claim_next("auto", term_id, AutoWorkTargetKind::Terminal, 60_000)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(claimed.owner_session_id, Some(term_id));
|
||||
assert_eq!(claimed.owner_kind.as_deref(), Some("terminal"));
|
||||
assert_eq!(claimed.status, RequirementStatus::InProgress);
|
||||
|
||||
// Delete the terminal through the service → the hook fires and clears owner.
|
||||
term_service.delete(term_id).await.unwrap();
|
||||
|
||||
let after = req_service.get(r.id).await.unwrap();
|
||||
assert_eq!(after.owner_session_id, None, "owner_session_id cleared on terminal delete");
|
||||
assert_eq!(after.owner_kind, None, "owner_kind cleared alongside (paired-NULL)");
|
||||
assert_eq!(
|
||||
after.status,
|
||||
RequirementStatus::Pending,
|
||||
"the orphaned in_progress requirement is re-pended"
|
||||
);
|
||||
assert_eq!(after.attempt_count, 1, "clearing owner must not consume an attempt");
|
||||
|
||||
Box::leak(Box::new(db));
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
//! Verifies `RequirementService::set_status` fires the `CompletionNotifier`
|
||||
//! exactly once on a terminal transition, and not on no-op / non-terminal ones.
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::sync::Mutex;
|
||||
use std::time::Duration;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use nomifun_api_types::{CreateRequirementRequest, RequirementStatus};
|
||||
use nomifun_db::models::RequirementRow;
|
||||
use nomifun_db::{IRequirementRepository, SqliteRequirementRepository, init_database_memory};
|
||||
use nomifun_realtime::EventBroadcaster;
|
||||
use nomifun_requirement::{CompletionNotifier, RequirementEventEmitter, RequirementService};
|
||||
|
||||
#[derive(Default)]
|
||||
struct NoopBroadcaster;
|
||||
impl EventBroadcaster for NoopBroadcaster {
|
||||
fn broadcast(&self, _event: nomifun_api_types::WebSocketMessage<serde_json::Value>) {}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct RecordingNotifier {
|
||||
ids: Mutex<Vec<i64>>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl CompletionNotifier for RecordingNotifier {
|
||||
async fn notify_completion(&self, requirement: &RequirementRow) {
|
||||
self.ids.lock().unwrap().push(requirement.id);
|
||||
}
|
||||
}
|
||||
|
||||
async fn svc(notifier: Arc<RecordingNotifier>) -> RequirementService {
|
||||
let db = init_database_memory().await.unwrap();
|
||||
let repo: Arc<dyn IRequirementRepository> = Arc::new(SqliteRequirementRepository::new(db.pool().clone()));
|
||||
let emitter = RequirementEventEmitter::new(Arc::new(NoopBroadcaster));
|
||||
Box::leak(Box::new(db));
|
||||
RequirementService::new(repo, emitter).with_completion_notifier(notifier)
|
||||
}
|
||||
|
||||
fn new_req(tag: &str) -> CreateRequirementRequest {
|
||||
CreateRequirementRequest {
|
||||
title: "T".into(),
|
||||
content: "body".into(),
|
||||
tag: tag.into(),
|
||||
order_key: Some("1".into()),
|
||||
status: None,
|
||||
created_by: None,
|
||||
attachments: vec![],
|
||||
}
|
||||
}
|
||||
|
||||
/// Let the detached `tokio::spawn`(notify) task run.
|
||||
async fn settle() {
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn fires_once_on_done() {
|
||||
let notifier = Arc::new(RecordingNotifier::default());
|
||||
let s = svc(notifier.clone()).await;
|
||||
let r = s.create(new_req("alpha")).await.unwrap();
|
||||
|
||||
s.set_status(r.id, RequirementStatus::Done, Some("note".into()))
|
||||
.await
|
||||
.unwrap();
|
||||
settle().await;
|
||||
assert_eq!(notifier.ids.lock().unwrap().as_slice(), std::slice::from_ref(&r.id));
|
||||
|
||||
// Re-setting the same terminal status is an idempotent no-op → no extra fire.
|
||||
let _ = s.set_status(r.id, RequirementStatus::Done, None).await.unwrap();
|
||||
settle().await;
|
||||
assert_eq!(notifier.ids.lock().unwrap().len(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn fires_on_failed() {
|
||||
let notifier = Arc::new(RecordingNotifier::default());
|
||||
let s = svc(notifier.clone()).await;
|
||||
let r = s.create(new_req("alpha")).await.unwrap();
|
||||
s.set_status(r.id, RequirementStatus::Failed, Some("oops".into()))
|
||||
.await
|
||||
.unwrap();
|
||||
settle().await;
|
||||
assert_eq!(notifier.ids.lock().unwrap().len(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn does_not_fire_on_non_terminal() {
|
||||
let notifier = Arc::new(RecordingNotifier::default());
|
||||
let s = svc(notifier.clone()).await;
|
||||
let r = s.create(new_req("alpha")).await.unwrap();
|
||||
s.set_status(r.id, RequirementStatus::InProgress, None).await.unwrap();
|
||||
settle().await;
|
||||
assert!(notifier.ids.lock().unwrap().is_empty());
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
//! Tests for `RequirementService::tag_bindings`: enumerates conversation +
|
||||
//! terminal AutoWork bindings, grouped by tag, only for enabled ones.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use nomifun_db::models::ConversationRow;
|
||||
use nomifun_db::{
|
||||
CreateTerminalParams, IConversationRepository, IRequirementRepository, ITerminalRepository,
|
||||
SqliteConversationRepository, SqliteRequirementRepository, SqliteTerminalRepository, init_database_memory,
|
||||
};
|
||||
use nomifun_realtime::EventBroadcaster;
|
||||
use nomifun_requirement::{RequirementEventEmitter, RequirementService};
|
||||
|
||||
#[derive(Default)]
|
||||
struct NoopBroadcaster;
|
||||
impl EventBroadcaster for NoopBroadcaster {
|
||||
fn broadcast(&self, _event: nomifun_api_types::WebSocketMessage<serde_json::Value>) {}
|
||||
}
|
||||
|
||||
fn conv(id: i64, name: &str, autowork_json: &str) -> ConversationRow {
|
||||
ConversationRow {
|
||||
id,
|
||||
user_id: "user_1".into(),
|
||||
name: name.into(),
|
||||
r#type: "nomi".into(),
|
||||
extra: autowork_json.into(),
|
||||
model: None,
|
||||
status: Some("pending".into()),
|
||||
source: None,
|
||||
channel_chat_id: None,
|
||||
pinned: false,
|
||||
pinned_at: None,
|
||||
cron_job_id: None,
|
||||
created_at: 0,
|
||||
updated_at: 0,
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn groups_enabled_conversation_and_terminal_bindings_by_tag() {
|
||||
let db = init_database_memory().await.unwrap();
|
||||
let pool = db.pool().clone();
|
||||
sqlx::query(
|
||||
"INSERT INTO users (id, username, password_hash, created_at, updated_at) \
|
||||
VALUES ('user_1', 'tester', 'h', 0, 0)",
|
||||
)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let conv_repo: Arc<dyn IConversationRepository> = Arc::new(SqliteConversationRepository::new(pool.clone()));
|
||||
let term_repo: Arc<dyn ITerminalRepository> = Arc::new(SqliteTerminalRepository::new(pool.clone()));
|
||||
let req_repo: Arc<dyn IRequirementRepository> = Arc::new(SqliteRequirementRepository::new(pool.clone()));
|
||||
|
||||
// Two conversations enabled on tag "x", one disabled, one with no autowork.
|
||||
// The id field is ignored on insert (SQLite mints the PK).
|
||||
conv_repo
|
||||
.create(&conv(1, "Alpha A", r#"{"autowork":{"enabled":true,"tag":"x"}}"#))
|
||||
.await
|
||||
.unwrap();
|
||||
conv_repo
|
||||
.create(&conv(2, "Alpha B", r#"{"autowork":{"enabled":true,"tag":"x"}}"#))
|
||||
.await
|
||||
.unwrap();
|
||||
conv_repo
|
||||
.create(&conv(
|
||||
3,
|
||||
"Disabled",
|
||||
r#"{"autowork":{"enabled":false,"tag":"x"}}"#,
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
conv_repo.create(&conv(4, "No autowork", "{}")).await.unwrap();
|
||||
|
||||
// One terminal enabled on tag "y". The id is minted by SQLite and returned.
|
||||
let term = term_repo
|
||||
.create(&CreateTerminalParams {
|
||||
name: "Term One".into(),
|
||||
cwd: "/tmp".into(),
|
||||
command: "claude".into(),
|
||||
args: "[]".into(),
|
||||
env: None,
|
||||
backend: Some("claude".into()),
|
||||
mode: None,
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
user_id: "user_1".into(),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
let term_id = term.id;
|
||||
term_repo
|
||||
.update_autowork(term_id, Some(r#"{"enabled":true,"tag":"y"}"#))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let svc = RequirementService::new(req_repo, RequirementEventEmitter::new(Arc::new(NoopBroadcaster)))
|
||||
.with_conversation_repo(conv_repo)
|
||||
.with_terminal_repo(term_repo);
|
||||
Box::leak(Box::new(db));
|
||||
|
||||
let groups = svc.tag_bindings("user_1").await.unwrap();
|
||||
|
||||
// tag "x" has the two enabled conversations; "y" has the terminal. Disabled +
|
||||
// no-autowork conversations are excluded.
|
||||
let x = groups.iter().find(|g| g.tag == "x").expect("tag x present");
|
||||
assert_eq!(x.bindings.len(), 2);
|
||||
let mut names: Vec<&str> = x.bindings.iter().map(|b| b.name.as_str()).collect();
|
||||
names.sort();
|
||||
assert_eq!(names, vec!["Alpha A", "Alpha B"]);
|
||||
|
||||
let y = groups.iter().find(|g| g.tag == "y").expect("tag y present");
|
||||
assert_eq!(y.bindings.len(), 1);
|
||||
assert_eq!(y.bindings[0].target_id, term_id.to_string());
|
||||
|
||||
// No "active" run_state without a live orchestrator (route enriches that).
|
||||
assert!(
|
||||
groups
|
||||
.iter()
|
||||
.flat_map(|g| &g.bindings)
|
||||
.all(|b| b.run_state == nomifun_api_types::AutoWorkRunState::Idle)
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user