582 lines
23 KiB
Rust
582 lines
23 KiB
Rust
//! 任务横向关联 Repo:TaskLinkRepo(task_links 表,知识图谱 Phase 1 V29)
|
|
//!
|
|
//! 表达任务间 depends_on / blocks / relates_to 关系,AI 拓扑排序编排调度的基础。
|
|
//! 对标设计 docs/02-架构设计/专项设计/项目知识图谱与任务队列系统-2026-06-26.md §2.2。
|
|
//!
|
|
//! 关键设计:
|
|
//! - `link_type` 白名单(depends_on/blocks/relates_to)应用层校验,非 DB 约束。
|
|
//! - 循环依赖(`depends_on` 链 A→B→A)在 `create_link` BFS 检测拒绝(非 DB 约束,对标 D8)。
|
|
//! - 跨项目依赖允许(现实中有跨项目依赖)。
|
|
//! - 软删除语义:Task 软删不级联删 link(恢复后关系还在)。
|
|
|
|
use std::collections::HashSet;
|
|
|
|
use rusqlite::{params, OptionalExtension, Row};
|
|
|
|
use df_types::error::Result;
|
|
|
|
use crate::db::Database;
|
|
use crate::models::TaskLinkRecord;
|
|
|
|
use super::{now_millis_str, storage_err};
|
|
|
|
// ============================================================
|
|
// link_type 白名单 + 校验
|
|
// ============================================================
|
|
|
|
/// `link_type` 白名单:只允许这三种关联类型(防拼写漂移 / 非法值进库)。
|
|
///
|
|
/// 对标设计 §2.2 link_type 语义:
|
|
/// - `depends_on`:source 依赖 target(target 完成后 source 才能开始)→ 拓扑排序调度
|
|
/// - `blocks`:source 阻塞 target(source 不完成则 target 无法推进)→ 依赖的反向声明
|
|
/// - `relates_to`:弱关联,无执行约束 → 上下文提示
|
|
pub const TASK_LINK_TYPES: &[&str] = &["depends_on", "blocks", "relates_to"];
|
|
|
|
/// 校验 `link_type` 在白名单内,否则返回 Err。
|
|
fn validate_link_type(link_type: &str) -> Result<()> {
|
|
if TASK_LINK_TYPES.contains(&link_type) {
|
|
Ok(())
|
|
} else {
|
|
Err(df_types::error::Error::Storage(format!(
|
|
"非法 link_type: {link_type},合法值: {:?}",
|
|
TASK_LINK_TYPES
|
|
)))
|
|
}
|
|
}
|
|
|
|
// ============================================================
|
|
// from_row 辅助函数
|
|
// ============================================================
|
|
|
|
fn task_link_from_row(row: &Row<'_>) -> std::result::Result<TaskLinkRecord, rusqlite::Error> {
|
|
Ok(TaskLinkRecord {
|
|
id: row.get("id")?,
|
|
source_id: row.get("source_id")?,
|
|
target_id: row.get("target_id")?,
|
|
link_type: row.get("link_type")?,
|
|
remark: row.get("remark")?,
|
|
created_at: row.get("created_at")?,
|
|
})
|
|
}
|
|
|
|
// ============================================================
|
|
// TaskLinkRepo
|
|
// ============================================================
|
|
|
|
/// 任务横向关联表 Repo(task_links,V29)。
|
|
///
|
|
/// 不走 `impl_repo!` 宏:① 表无 `updated_at`/`deleted_at`(link 不可改只增删,审计简单);
|
|
/// ② `create_link` 需在 INSERT 前做 BFS 循环依赖检测(宏生成的 insert 无业务前置逻辑)。
|
|
/// 故手写专用方法,对标 SettingsRepo 全专用路径。
|
|
pub struct TaskLinkRepo {
|
|
conn: std::sync::Arc<tokio::sync::Mutex<rusqlite::Connection>>,
|
|
}
|
|
|
|
impl TaskLinkRepo {
|
|
pub fn new(db: &Database) -> Self {
|
|
Self { conn: db.conn() }
|
|
}
|
|
|
|
/// 创建任务关联(应用层校验 link_type 白名单 + depends_on 链 BFS 循环依赖检测)。
|
|
///
|
|
/// 对标设计 §2.2 边界约束:
|
|
/// - `link_type` 必须在白名单(depends_on/blocks/relates_to),否则 Err。
|
|
/// - 自环(source_id == target_id)直接拒绝(无意义的自依赖)。
|
|
/// - 循环依赖检测:**仅对 depends_on 链**做 BFS。blocks 是 depends_on 的反向声明,
|
|
/// relates_to 无执行约束,二者不参与环检测(否则弱关联也会触发拒绝,过度约束)。
|
|
/// BFS 思路:新增 source→target 后,从 target 出发沿 depends_on 链向下遍历,
|
|
/// 若能回到 source 则成环,拒绝。数据量小(单项目 ~50 任务),全表遍历无压力(对标 D8)。
|
|
/// - 跨项目允许(不做 project_id 一致性校验,现实有跨项目依赖)。
|
|
///
|
|
/// 返回插入的 link id。
|
|
pub async fn create_link(
|
|
&self,
|
|
id: &str,
|
|
source_id: &str,
|
|
target_id: &str,
|
|
link_type: &str,
|
|
remark: Option<&str>,
|
|
) -> Result<String> {
|
|
// 应用层校验先行(fail-fast,非法值不进 DB 层)
|
|
validate_link_type(link_type)?;
|
|
if source_id == target_id {
|
|
return Err(df_types::error::Error::Storage(format!(
|
|
"非法 task_link: source_id 与 target_id 相同({source_id}),自环无意义"
|
|
)));
|
|
}
|
|
|
|
let conn = self.conn.clone();
|
|
let id = id.to_owned();
|
|
let source_id_o = source_id.to_owned();
|
|
let target_id_o = target_id.to_owned();
|
|
let link_type_o = link_type.to_owned();
|
|
let remark_o = remark.map(|s| s.to_owned());
|
|
let now = now_millis_str();
|
|
|
|
tokio::task::spawn_blocking(move || {
|
|
let guard = conn.blocking_lock();
|
|
|
|
// depends_on 链 BFS 环检测:新增 source→target 边后,从 target 沿 depends_on
|
|
// 向下走,若能回到 source 即成环。在 INSERT 前检测,避免脏数据(检测与插入非原子,
|
|
// 但单用户桌面应用无并发,够用)。
|
|
if link_type_o == "depends_on" {
|
|
if bfs_reaches(&guard, &target_id_o, &source_id_o)? {
|
|
return Err(df_types::error::Error::Storage(format!(
|
|
"非法 task_link: {} depends_on {} 会形成循环依赖",
|
|
source_id_o, target_id_o
|
|
)));
|
|
}
|
|
}
|
|
|
|
guard
|
|
.execute(
|
|
"INSERT INTO task_links (id, source_id, target_id, link_type, remark, created_at)
|
|
VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
|
|
params![id, source_id_o, target_id_o, link_type_o, remark_o, now],
|
|
)
|
|
.map_err(storage_err)?;
|
|
Ok(id)
|
|
})
|
|
.await
|
|
.map_err(storage_err)?
|
|
}
|
|
|
|
/// 删除关联(按 id),返回是否命中。
|
|
pub async fn delete(&self, id: &str) -> Result<bool> {
|
|
let conn = self.conn.clone();
|
|
let id = id.to_owned();
|
|
tokio::task::spawn_blocking(move || {
|
|
let guard = conn.blocking_lock();
|
|
let affected = guard
|
|
.execute("DELETE FROM task_links WHERE id = ?1", params![id])
|
|
.map_err(storage_err)?;
|
|
Ok(affected > 0)
|
|
})
|
|
.await
|
|
.map_err(storage_err)?
|
|
}
|
|
|
|
/// 按 id 取单条(对标 IdeaRepo::get_by_id 模式)。
|
|
pub async fn get_by_id(&self, id: &str) -> Result<Option<TaskLinkRecord>> {
|
|
let conn = self.conn.clone();
|
|
let id = id.to_owned();
|
|
tokio::task::spawn_blocking(move || {
|
|
let guard = conn.blocking_lock();
|
|
let row = guard
|
|
.query_row(
|
|
"SELECT id, source_id, target_id, link_type, remark, created_at \
|
|
FROM task_links WHERE id = ?1",
|
|
params![id],
|
|
task_link_from_row,
|
|
)
|
|
.optional()
|
|
.map_err(storage_err)?;
|
|
Ok(row)
|
|
})
|
|
.await
|
|
.map_err(storage_err)?
|
|
}
|
|
|
|
/// 按 source_id 查询(source 主动声明的全部关联),命中 idx_task_links_source。
|
|
/// AI 编排调度用:取某任务的全部依赖 / 阻塞 / 关联。
|
|
pub async fn get_by_source(&self, source_id: &str) -> Result<Vec<TaskLinkRecord>> {
|
|
let conn = self.conn.clone();
|
|
let source_id = source_id.to_owned();
|
|
tokio::task::spawn_blocking(move || {
|
|
let guard = conn.blocking_lock();
|
|
let mut stmt = guard
|
|
.prepare(
|
|
"SELECT id, source_id, target_id, link_type, remark, created_at \
|
|
FROM task_links WHERE source_id = ?1 ORDER BY created_at ASC",
|
|
)
|
|
.map_err(storage_err)?;
|
|
let rows = stmt
|
|
.query_map(params![source_id], task_link_from_row)
|
|
.map_err(storage_err)?;
|
|
let mut results = Vec::new();
|
|
for r in rows {
|
|
results.push(r.map_err(storage_err)?);
|
|
}
|
|
Ok(results)
|
|
})
|
|
.await
|
|
.map_err(storage_err)?
|
|
}
|
|
|
|
/// 按 target_id 查询(谁指向了 target),命中 idx_task_links_target。
|
|
/// AI 编排调度用:「谁依赖了我」「谁被我阻塞」反向查询(JSON 列方案无法高效反查的痛点)。
|
|
pub async fn get_by_target(&self, target_id: &str) -> Result<Vec<TaskLinkRecord>> {
|
|
let conn = self.conn.clone();
|
|
let target_id = target_id.to_owned();
|
|
tokio::task::spawn_blocking(move || {
|
|
let guard = conn.blocking_lock();
|
|
let mut stmt = guard
|
|
.prepare(
|
|
"SELECT id, source_id, target_id, link_type, remark, created_at \
|
|
FROM task_links WHERE target_id = ?1 ORDER BY created_at ASC",
|
|
)
|
|
.map_err(storage_err)?;
|
|
let rows = stmt
|
|
.query_map(params![target_id], task_link_from_row)
|
|
.map_err(storage_err)?;
|
|
let mut results = Vec::new();
|
|
for r in rows {
|
|
results.push(r.map_err(storage_err)?);
|
|
}
|
|
Ok(results)
|
|
})
|
|
.await
|
|
.map_err(storage_err)?
|
|
}
|
|
|
|
/// 列出全部关联(调试/管理用,按创建时间升序)。
|
|
pub async fn list_all(&self) -> Result<Vec<TaskLinkRecord>> {
|
|
let conn = self.conn.clone();
|
|
tokio::task::spawn_blocking(move || {
|
|
let guard = conn.blocking_lock();
|
|
let mut stmt = guard
|
|
.prepare(
|
|
"SELECT id, source_id, target_id, link_type, remark, created_at \
|
|
FROM task_links ORDER BY created_at ASC",
|
|
)
|
|
.map_err(storage_err)?;
|
|
let rows = stmt.query_map([], task_link_from_row).map_err(storage_err)?;
|
|
let mut results = Vec::new();
|
|
for r in rows {
|
|
results.push(r.map_err(storage_err)?);
|
|
}
|
|
Ok(results)
|
|
})
|
|
.await
|
|
.map_err(storage_err)?
|
|
}
|
|
}
|
|
|
|
// ============================================================
|
|
// BFS 循环依赖检测(仅 depends_on 链)
|
|
// ============================================================
|
|
|
|
/// BFS:从 `start` 出发沿 depends_on 链(source→target 方向)向下遍历,判断能否到达 `target_node`。
|
|
///
|
|
/// 用于 `create_link(source, depends_on, target)` 前的环检测:新增 source→target 边后,
|
|
/// 若 target 能经 depends_on 链回到 source,则形成 source→target→...→source 闭环。
|
|
/// 故调用 `bfs_reaches(conn, start=target, target_node=source)`:从 target 出发看能否到 source。
|
|
///
|
|
/// 实现细节:
|
|
/// - 遍历 `WHERE source_id = ? AND link_type = 'depends_on'` 取下一跳(对标设计 D8:数据量小,
|
|
/// 全表遍历无压力,不走 SQL 递归 CTE,纯 Rust BFS)。
|
|
/// - `visited` HashSet 防重复访问(菱形依赖 A→B,A→C,B→D,C→D 不应死循环)。
|
|
/// - 防御性深度上限(10000 跳):万一数据异常成环未被拦截,BFS 不致无限循环。
|
|
fn bfs_reaches(
|
|
conn: &rusqlite::Connection,
|
|
start: &str,
|
|
target_node: &str,
|
|
) -> Result<bool> {
|
|
use std::collections::VecDeque;
|
|
|
|
let mut visited: HashSet<String> = HashSet::new();
|
|
let mut queue: VecDeque<String> = VecDeque::new();
|
|
queue.push_back(start.to_owned());
|
|
visited.insert(start.to_owned());
|
|
|
|
let mut depth = 0u32;
|
|
const MAX_DEPTH: u32 = 10_000;
|
|
|
|
while let Some(node) = queue.pop_front() {
|
|
depth += 1;
|
|
if depth > MAX_DEPTH {
|
|
// 防御性兜底:正常依赖链不可能这么深,到这要么数据异常要么已有环未被拦截。
|
|
// 保守视为成环(拒绝创建),避免无限循环 + 暴露异常数据。
|
|
tracing::warn!(
|
|
"task_links BFS 超过 {MAX_DEPTH} 跳,疑似数据异常,保守拒绝创建"
|
|
);
|
|
return Ok(true);
|
|
}
|
|
|
|
if node == target_node {
|
|
return Ok(true);
|
|
}
|
|
|
|
// 取该节点作为 source 的全部 depends_on 边的 target(下一跳)
|
|
let mut stmt = conn
|
|
.prepare(
|
|
"SELECT target_id FROM task_links \
|
|
WHERE source_id = ?1 AND link_type = 'depends_on'",
|
|
)
|
|
.map_err(storage_err)?;
|
|
let nexts: Vec<String> = stmt
|
|
.query_map(params![node], |row| row.get::<_, String>(0))
|
|
.map_err(storage_err)?
|
|
.filter_map(|r| r.ok())
|
|
.collect();
|
|
|
|
for next in nexts {
|
|
if visited.insert(next.clone()) {
|
|
queue.push_back(next);
|
|
}
|
|
}
|
|
}
|
|
Ok(false)
|
|
}
|
|
|
|
// ============================================================
|
|
// 单元测试 — TaskLinkRepo CRUD + 循环依赖拒绝(内存 DB,对标 idea_repo 测试)
|
|
// ============================================================
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::crud::TaskRepo;
|
|
use crate::models::TaskRecord;
|
|
use df_types::types::{ProjectStatus, TaskStatus};
|
|
|
|
/// 构造一条 TaskRecord fixture(18 字段全填,queue 默认 todo)。
|
|
fn trec(id: &str, project_id: &str) -> TaskRecord {
|
|
TaskRecord {
|
|
id: id.to_string(),
|
|
project_id: project_id.to_string(),
|
|
title: format!("task-{id}"),
|
|
description: String::new(),
|
|
status: TaskStatus::Todo,
|
|
priority: 1,
|
|
branch_name: None,
|
|
assignee: None,
|
|
workflow_def_id: None,
|
|
base_branch: None,
|
|
review_rounds: 0,
|
|
output_json: None,
|
|
idea_id: None,
|
|
queue: "todo".to_string(),
|
|
parent_id: None,
|
|
content_json: None,
|
|
created_at: "1700000000000".to_string(),
|
|
updated_at: "1700000000000".to_string(),
|
|
}
|
|
}
|
|
|
|
/// 构造内存 DB + 注入若干任务(task_links FK 要求 tasks 存在,
|
|
/// tasks.project_id FK 要求 projects 存在 → 先建占位 project 满足 FK 约束,
|
|
/// 对标 tests/project_soft_delete.rs 集成测试的 setup 模式)。
|
|
async fn setup_with_tasks(ids: &[&str]) -> (crate::db::Database, TaskLinkRepo, TaskRepo) {
|
|
let db = crate::db::Database::open_in_memory()
|
|
.await
|
|
.expect("open_in_memory");
|
|
let link_repo = TaskLinkRepo::new(&db);
|
|
let task_repo = TaskRepo::new(&db);
|
|
// 先建占位 project 满足 tasks.project_id FK(PRAGMA foreign_keys=ON,db.rs:37/37)
|
|
let project_repo = crate::crud::ProjectRepo::new(&db);
|
|
project_repo
|
|
.insert(crate::models::ProjectRecord {
|
|
id: "proj-1".to_string(),
|
|
name: "proj-1".to_string(),
|
|
description: String::new(),
|
|
status: ProjectStatus::Planning,
|
|
idea_id: None,
|
|
path: None,
|
|
stack: None,
|
|
created_at: "1700000000000".to_string(),
|
|
updated_at: "1700000000000".to_string(),
|
|
})
|
|
.await
|
|
.unwrap();
|
|
for id in ids {
|
|
task_repo.insert(trec(id, "proj-1")).await.unwrap();
|
|
}
|
|
(db, link_repo, task_repo)
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn validate_link_type_rejects_unknown() {
|
|
assert!(validate_link_type("depends_on").is_ok());
|
|
assert!(validate_link_type("blocks").is_ok());
|
|
assert!(validate_link_type("relates_to").is_ok());
|
|
assert!(validate_link_type("unknown").is_err());
|
|
assert!(validate_link_type("").is_err());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn create_link_basic_depends_on() {
|
|
let (_db, repo, _task_repo) = setup_with_tasks(&["a", "b"]).await;
|
|
let id = repo
|
|
.create_link("l1", "a", "b", "depends_on", None)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(id, "l1");
|
|
|
|
let got = repo.get_by_id("l1").await.unwrap().expect("link 存在");
|
|
assert_eq!(got.source_id, "a");
|
|
assert_eq!(got.target_id, "b");
|
|
assert_eq!(got.link_type, "depends_on");
|
|
assert!(got.remark.is_none());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn create_link_with_remark() {
|
|
let (_db, repo, _) = setup_with_tasks(&["a", "b"]).await;
|
|
repo.create_link("l1", "a", "b", "blocks", Some("阻塞说明"))
|
|
.await
|
|
.unwrap();
|
|
let got = repo.get_by_id("l1").await.unwrap().unwrap();
|
|
assert_eq!(got.link_type, "blocks");
|
|
assert_eq!(got.remark.as_deref(), Some("阻塞说明"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn create_link_rejects_invalid_type() {
|
|
let (_db, repo, _) = setup_with_tasks(&["a", "b"]).await;
|
|
let err = repo
|
|
.create_link("l1", "a", "b", "invalid_type", None)
|
|
.await;
|
|
assert!(err.is_err(), "非法 link_type 应被拒绝");
|
|
// 未入库
|
|
assert!(repo.get_by_id("l1").await.unwrap().is_none());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn create_link_rejects_self_loop() {
|
|
let (_db, repo, _) = setup_with_tasks(&["a"]).await;
|
|
let err = repo.create_link("l1", "a", "a", "depends_on", None).await;
|
|
assert!(err.is_err(), "自环 source==target 应被拒绝");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn get_by_source_and_target() {
|
|
let (_db, repo, _) = setup_with_tasks(&["a", "b", "c"]).await;
|
|
repo.create_link("l1", "a", "b", "depends_on", None)
|
|
.await
|
|
.unwrap();
|
|
repo.create_link("l2", "a", "c", "relates_to", None)
|
|
.await
|
|
.unwrap();
|
|
repo.create_link("l3", "c", "b", "blocks", None)
|
|
.await
|
|
.unwrap();
|
|
|
|
// a 作为 source 声明了 2 条(l1/l2)
|
|
let from_a = repo.get_by_source("a").await.unwrap();
|
|
let ids: Vec<_> = from_a.iter().map(|l| l.id.as_str()).collect();
|
|
assert_eq!(ids, vec!["l1", "l2"]);
|
|
|
|
// b 作为 target 被 2 条指向(l1/l3)
|
|
let to_b = repo.get_by_target("b").await.unwrap();
|
|
let ids: Vec<_> = to_b.iter().map(|l| l.id.as_str()).collect();
|
|
assert_eq!(ids, vec!["l1", "l3"]);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn delete_link() {
|
|
let (_db, repo, _) = setup_with_tasks(&["a", "b"]).await;
|
|
repo.create_link("l1", "a", "b", "depends_on", None)
|
|
.await
|
|
.unwrap();
|
|
assert!(repo.delete("l1").await.unwrap());
|
|
assert!(repo.get_by_id("l1").await.unwrap().is_none());
|
|
// 再删返回 false
|
|
assert!(!repo.delete("l1").await.unwrap());
|
|
}
|
|
|
|
// ---------- 循环依赖 BFS 检测(仅 depends_on 链)----------
|
|
|
|
#[tokio::test]
|
|
async fn cycle_direct_a_depends_b_then_b_depends_a_rejected() {
|
|
// A→B 合法;再 B→A 应形成 A→B→A 闭环,拒绝
|
|
let (_db, repo, _) = setup_with_tasks(&["a", "b"]).await;
|
|
repo.create_link("l1", "a", "b", "depends_on", None)
|
|
.await
|
|
.unwrap();
|
|
let err = repo
|
|
.create_link("l2", "b", "a", "depends_on", None)
|
|
.await;
|
|
assert!(err.is_err(), "A→B→A 循环依赖应被拒绝");
|
|
// l2 未入库
|
|
assert!(repo.get_by_id("l2").await.unwrap().is_none());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn cycle_three_nodes_rejected() {
|
|
// A→B→C 合法;再 C→A 形成 A→B→C→A 闭环,拒绝
|
|
let (_db, repo, _) = setup_with_tasks(&["a", "b", "c"]).await;
|
|
repo.create_link("l1", "a", "b", "depends_on", None)
|
|
.await
|
|
.unwrap();
|
|
repo.create_link("l2", "b", "c", "depends_on", None)
|
|
.await
|
|
.unwrap();
|
|
let err = repo
|
|
.create_link("l3", "c", "a", "depends_on", None)
|
|
.await;
|
|
assert!(err.is_err(), "A→B→C→A 三节点循环依赖应被拒绝");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn diamond_dependency_not_cycle() {
|
|
// 菱形依赖 A→B, A→C, B→D, C→D 是 DAG 非环,D 不应再指向 A/B/C
|
|
// 关键:BFS 遇菱形不误判(D 被两条路径到达,visited 去重不死循环)
|
|
let (_db, repo, _) = setup_with_tasks(&["a", "b", "c", "d"]).await;
|
|
repo.create_link("l1", "a", "b", "depends_on", None)
|
|
.await
|
|
.unwrap();
|
|
repo.create_link("l2", "a", "c", "depends_on", None)
|
|
.await
|
|
.unwrap();
|
|
repo.create_link("l3", "b", "d", "depends_on", None)
|
|
.await
|
|
.unwrap();
|
|
repo.create_link("l4", "c", "d", "depends_on", None)
|
|
.await
|
|
.unwrap();
|
|
// 全部成功(菱形合法)
|
|
assert_eq!(repo.list_all().await.unwrap().len(), 4);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn blocks_link_does_not_trigger_cycle_check() {
|
|
// blocks 是 depends_on 的反向声明,不参与环检测:A blocks B + B blocks A 应都合法
|
|
// (虽然语义重复,但环检测只管 depends_on 链,blocks/relates_to 弱约束不强拦)
|
|
let (_db, repo, _) = setup_with_tasks(&["a", "b"]).await;
|
|
repo.create_link("l1", "a", "b", "blocks", None)
|
|
.await
|
|
.unwrap();
|
|
// B blocks A 不触发 depends_on 环检测,合法
|
|
repo.create_link("l2", "b", "a", "blocks", None)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(repo.list_all().await.unwrap().len(), 2);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn relates_to_link_does_not_trigger_cycle_check() {
|
|
// relates_to 弱关联无执行约束,不参与环检测
|
|
let (_db, repo, _) = setup_with_tasks(&["a", "b"]).await;
|
|
repo.create_link("l1", "a", "b", "relates_to", None)
|
|
.await
|
|
.unwrap();
|
|
repo.create_link("l2", "b", "a", "relates_to", None)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(repo.list_all().await.unwrap().len(), 2);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn cross_project_link_allowed() {
|
|
// 跨项目依赖允许(设计 §2.2 边界):不做 project_id 一致性校验
|
|
let (_db, repo, _) = setup_with_tasks(&["a", "b"]).await; // fixture 同 proj-1,但 repo 不校验
|
|
let res = repo.create_link("l1", "a", "b", "depends_on", None).await;
|
|
assert!(res.is_ok(), "跨项目依赖应允许(repo 层不校验 project 一致性)");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn cycle_check_mixed_chain_only_depends_on_matters() {
|
|
// 混合链:A relates_to B(弱关联),B depends_on A 应合法(relates_to 不构成环路径)
|
|
let (_db, repo, _) = setup_with_tasks(&["a", "b"]).await;
|
|
repo.create_link("l1", "a", "b", "relates_to", None)
|
|
.await
|
|
.unwrap();
|
|
// B depends_on A:BFS 从 A 出发沿 depends_on 找 B,A 无 depends_on 出边 → 不到 B → 合法
|
|
repo.create_link("l2", "b", "a", "depends_on", None)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(repo.list_all().await.unwrap().len(), 2);
|
|
}
|
|
}
|