新增: 知识路标投影进系统提示词
- published 且验证或高复用条目按项目优先进投影稳定段 - 段尾固定引导工具故障先检索路标,治有经验不想查 - 与规则文件投影共用会话级缓存,空库零回归
This commit is contained in:
@@ -1,24 +1,35 @@
|
||||
//! devflow.md / CLAUDE.md 项目认知投影(项目规则文件 → system prompt 稳定尾段)。
|
||||
//! devflow.md / CLAUDE.md 项目认知投影(项目规则文件 + 知识库路标 → system prompt 稳定尾段)。
|
||||
//!
|
||||
//! 定位:对标 CLAUDE.md 的"启动读一遍静态规则文件",本土化为双文件并读——
|
||||
//! - `devflow.md`:agent 生态自己的项目认知文件,v1 取两个分区(规范纪律 / 领单);
|
||||
//! - `CLAUDE.md`:既有通用约定,同目录存在则并入(增强非依赖)。
|
||||
//! 两文件并存时 devflow.md 优先(段内排序在前 + 显式冲突注明)。
|
||||
//!
|
||||
//! 知识路标子段(知识投影 v2):规则文件只说"该怎么做",路标补"库里有啥"——已发布且
|
||||
//! (人工验证或复用达标)的知识条目按「同项目档 + 跨项目补充档」铺开成紧凑行,
|
||||
//! 让 agent 知晓知识库藏、在工具故障/重复失败时想起用 knowledge_search 检索,
|
||||
//! 而不是死磕。读侧走 df-storage [`KnowledgeRepo::list_waymark`],会话项目归属复用
|
||||
//! [`resolve_source_project`] 既有机制,不自写 SQL 不造双轨。
|
||||
//!
|
||||
//! 缓存红线:投影段纯追加在 system prompt 全部既有段之后,内容按会话缓存——
|
||||
//! 同一会话(含其派生的子代理)只在首次组装时读一次盘,此后复用同一文本,
|
||||
//! 同一会话(含其派生的子代理)只在首次组装时读一次盘 + 查一次库,此后复用同一文本,
|
||||
//! 保证段内容会话内逐字节不变,不击穿 provider 的 prompt 缓存前缀。
|
||||
//! 会话粒度取 conv_id 作缓存键:新会话重新读盘(拿到文件最新内容),旧会话不受扰动。
|
||||
//! 会话粒度取 conv_id 作缓存键:新会话重新组装(拿到文件与库的最新内容),旧会话不受扰动。
|
||||
//!
|
||||
//! 海纳川流:两文件缺失 / 读取失败 / 内容为空 → 投影段为空,prompt 与不接本模块
|
||||
//! 逐字一致(规则文件是增强输入,缺失不影响内生运作)。
|
||||
//! 海纳川流:两文件缺失 / 读取失败 / 内容为空,且知识库无合格条目 → 投影段为空,
|
||||
//! prompt 与不接本模块逐字一致(规则文件与路标都是增强输入,缺失不影响内生运作)。
|
||||
//!
|
||||
//! 裁剪纪律:注入的 token 才是成本——按分区截取 + 字符上限,禁全量灌入。
|
||||
//! 超限按 Unicode 字符截断(中文一字一符,按字节会截断 UTF-8 序列产生乱码)并注明。
|
||||
//! 裁剪纪律:注入的 token 才是成本——规则分区按字符上限截断,路标条目数被查询
|
||||
//! LIMIT 封顶 + 行内标题截断,整体体量有界。超限按 Unicode 字符截断
|
||||
//! (中文一字一符,按字节会截断 UTF-8 序列产生乱码)并注明。
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
use std::sync::{Arc, Mutex, OnceLock};
|
||||
|
||||
use df_storage::crud::KnowledgeRepo;
|
||||
use df_storage::db::Database;
|
||||
use df_storage::models::KnowledgeRecord;
|
||||
|
||||
use crate::state::AllowedDirs;
|
||||
|
||||
@@ -39,6 +50,17 @@ const SECTION_MAX_CHARS: usize = 6000;
|
||||
/// (清空后下次组装重读盘,内容只会更新不会错,正确性不受损)。
|
||||
const SECTION_CACHE_MAX: usize = 64;
|
||||
|
||||
// ── 知识路标子段参数 ──
|
||||
//
|
||||
// 条目数由查询 LIMIT 封顶(项目档 + 补充档),行内标题截断注省略号——双层结构上界
|
||||
// 使路标块体量有界(约 30 行 × 行宽上限),无须再设整块字符闸。
|
||||
/// 会话归属项目档上限(条)
|
||||
const WAYMARK_PROJECT_MAX: usize = 20;
|
||||
/// 跨项目补充档上限(条)
|
||||
const WAYMARK_GLOBAL_MAX: usize = 10;
|
||||
/// 路标行标题上限(字符):超长截断防长标题破坏紧凑行形态
|
||||
const WAYMARK_TITLE_MAX_CHARS: usize = 60;
|
||||
|
||||
/// devflow.md 规范分区识别的标题关键字(领单优先判,防"领单纪律"被纪律关键字截胡)
|
||||
const CLAIM_HEADING_KEY: &str = "领单";
|
||||
const DISCIPLINE_HEADING_KEYS: [&str; 2] = ["纪律", "规范"];
|
||||
@@ -49,6 +71,16 @@ fn section_cache() -> &'static Mutex<HashMap<String, String>> {
|
||||
SECTION_CACHE.get_or_init(|| Mutex::new(HashMap::new()))
|
||||
}
|
||||
|
||||
/// 知识库读侧句柄:进程级 AppHandle → AppState.db(生产装配点,与 spawn_agent 全局
|
||||
/// 同款"注册期无法捕获、运行期读取"形态)。AppHandle 未注入 / AppState 未托管
|
||||
/// (早期启动、单元测试)→ None,路标子块整体缺席,其余组装零扰动。
|
||||
fn knowledge_db() -> Option<Arc<Database>> {
|
||||
use tauri::Manager;
|
||||
let app = crate::commands::ai::tools::spawn_agent::app_handle()?;
|
||||
let state = app.try_state::<crate::state::AppState>()?;
|
||||
Some(state.db.clone())
|
||||
}
|
||||
|
||||
/// 工作目录解析:首个持久授权目录(排序取首稳定)。
|
||||
///
|
||||
/// 与主链被监视工作区取同一约定,保证主会话与子代理对"项目根"的认知一致;
|
||||
@@ -64,17 +96,19 @@ pub(crate) fn work_dir_of(allowed: &AllowedDirs) -> Option<String> {
|
||||
/// 无投影内容时原样返回(调用方 prompt 逐字不变);有则 `\n\n` 接续,
|
||||
/// 调用方既有段内容不受任何影响(稳定尾段语义)。
|
||||
pub(crate) async fn append_projection(prompt: String, work_dir: Option<&str>, conv_id: &str) -> String {
|
||||
match projection_section(work_dir, conv_id).await {
|
||||
let kb = knowledge_db();
|
||||
match projection_section(work_dir, conv_id, kb).await {
|
||||
Some(seg) => format!("{}\n\n{}", prompt, seg),
|
||||
None => prompt,
|
||||
}
|
||||
}
|
||||
|
||||
/// 取会话的投影段:缓存命中直接复用(同会话零重复读盘);未命中读盘一次并入缓存。
|
||||
/// 取会话的投影段:缓存命中直接复用(同会话零重复读盘查库);未命中组装一次并入缓存。
|
||||
///
|
||||
/// 缓存值空串 = 本会话已知无投影(文件缺失),同样走缓存避免每轮白读盘。
|
||||
/// 缓存值空串 = 本会话已知无投影(文件缺失且库无合格条目),同样走缓存避免每轮白查。
|
||||
/// 子代理以父会话 conv_id 作键,天然复用父会话已读内容。
|
||||
async fn projection_section(work_dir: Option<&str>, conv_id: &str) -> Option<String> {
|
||||
/// `kb`:知识库读侧句柄(None = 单元测试/未托管态,路标子块整体缺席)。
|
||||
async fn projection_section(work_dir: Option<&str>, conv_id: &str, kb: Option<Arc<Database>>) -> Option<String> {
|
||||
{
|
||||
let cache = section_cache().lock().unwrap_or_else(|p| p.into_inner());
|
||||
if let Some(hit) = cache.get(conv_id) {
|
||||
@@ -82,15 +116,23 @@ async fn projection_section(work_dir: Option<&str>, conv_id: &str) -> Option<Str
|
||||
}
|
||||
}
|
||||
let dir = work_dir.map(PathBuf::from);
|
||||
// 文件读放阻塞线程池(约定与 system prompt 指纹查询一致),失败按缺失处理
|
||||
let (devflow, claude) = tokio::task::spawn_blocking(move || read_pair(dir))
|
||||
.await
|
||||
.unwrap_or_else(|_| (None, None));
|
||||
let conv = conv_id.to_string();
|
||||
// 规则文件读盘(阻塞线程池,失败按缺失处理)与知识路标查库相互独立,并发取
|
||||
let (files, waymark) = tokio::join!(
|
||||
tokio::task::spawn_blocking(move || read_pair(dir)),
|
||||
async {
|
||||
match kb {
|
||||
Some(db) => build_waymark(&db, &conv).await,
|
||||
None => None,
|
||||
}
|
||||
}
|
||||
);
|
||||
let (devflow, claude) = files.unwrap_or_else(|_| (None, None));
|
||||
let (discipline, claiming) = devflow
|
||||
.as_deref()
|
||||
.map(extract_partitions)
|
||||
.unwrap_or((String::new(), String::new()));
|
||||
let section = build_section(&discipline, &claiming, claude.as_deref());
|
||||
let section = combine_sections(build_section(&discipline, &claiming, claude.as_deref()), waymark);
|
||||
let mut cache = section_cache().lock().unwrap_or_else(|p| p.into_inner());
|
||||
if cache.len() >= SECTION_CACHE_MAX {
|
||||
cache.clear();
|
||||
@@ -99,6 +141,59 @@ async fn projection_section(work_dir: Option<&str>, conv_id: &str) -> Option<Str
|
||||
section
|
||||
}
|
||||
|
||||
/// 规则文件投影 + 知识路标子块的拼装(纯函数):路标恒居规则投影之后(同一稳定尾段内);
|
||||
/// 双双缺席 → None(调用方零追加,零回归)。仅路标在场 → 路标独占投影段(规则文件头语不出现)。
|
||||
fn combine_sections(rules: Option<String>, waymark: Option<String>) -> Option<String> {
|
||||
match (rules, waymark) {
|
||||
(Some(mut rules), Some(wm)) => {
|
||||
rules.push_str("\n\n");
|
||||
rules.push_str(&wm);
|
||||
Some(rules)
|
||||
}
|
||||
(Some(rules), None) => Some(rules),
|
||||
(None, Some(wm)) => Some(format!("## 项目认知投影\n\n{wm}")),
|
||||
(None, None) => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 组装知识路标子块:解析会话归属项目(既有机制) → 两档查询 → 渲染紧凑行。
|
||||
/// 查询失败降级缺席(warn 留痕,不阻断组装:路标是增强输入,故障不该影响 prompt 主链)。
|
||||
async fn build_waymark(db: &Arc<Database>, conv_id: &str) -> Option<String> {
|
||||
let project = crate::commands::ai::knowledge_inject::resolve_source_project(db, conv_id, &[]).await;
|
||||
match KnowledgeRepo::new(db)
|
||||
.list_waymark(project.as_deref(), WAYMARK_PROJECT_MAX, WAYMARK_GLOBAL_MAX)
|
||||
.await
|
||||
{
|
||||
Ok(list) if !list.is_empty() => Some(render_waymark(&list)),
|
||||
Ok(_) => None,
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %e, conv_id, "[devflow-md] 知识路标查询失败,子块缺席");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 渲染路标子块(纯函数):段首说明 + 紧凑行 + 段尾固定提示。
|
||||
/// 标题压平空白(库内标题可能含换行,直拼会破坏单行形态)并截断注省略号;
|
||||
/// 空白标题条目跳过(渲染不出可读行的脏数据不入段)。
|
||||
fn render_waymark(entries: &[KnowledgeRecord]) -> String {
|
||||
let mut wm = String::from("### 知识库路标\n项目知识库路标(详情用 knowledge_search 按标题关键词检索)");
|
||||
for e in entries {
|
||||
let flat = e.title.split_whitespace().collect::<Vec<_>>().join(" ");
|
||||
if flat.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let count = flat.chars().count();
|
||||
let mut title: String = flat.chars().take(WAYMARK_TITLE_MAX_CHARS).collect();
|
||||
if count > WAYMARK_TITLE_MAX_CHARS {
|
||||
title.push('…');
|
||||
}
|
||||
wm.push_str(&format!("\n- {title} | 复用{}次", e.reuse_count));
|
||||
}
|
||||
wm.push_str("\n上述经验条目可直接复用;遇到工具故障/重复失败先检索路标");
|
||||
wm
|
||||
}
|
||||
|
||||
/// 读工作目录下的两份规则文件,内容 trim 后返回;目录缺失 / 读失败 / 空白内容 → None。
|
||||
fn read_pair(dir: Option<PathBuf>) -> (Option<String>, Option<String>) {
|
||||
let Some(dir) = dir else {
|
||||
@@ -114,8 +209,8 @@ fn read_trimmed(path: &Path) -> Option<String> {
|
||||
.filter(|s| !s.is_empty())
|
||||
}
|
||||
|
||||
/// 拼装投影段(纯函数):分区截取、上限截断、优先级注明都在这里定形。
|
||||
/// 三路输入全空 → None(调用方零追加)。
|
||||
/// 拼装规则文件投影段(纯函数):分区截取、上限截断、优先级注明都在这里定形。
|
||||
/// 三路输入全空 → None(路标子块在场时由 combine_sections 单独成段)。
|
||||
fn build_section(discipline: &str, claiming: &str, claude: Option<&str>) -> Option<String> {
|
||||
let claude = claude.map(str::trim).filter(|c| !c.is_empty());
|
||||
if discipline.is_empty() && claiming.is_empty() && claude.is_none() {
|
||||
@@ -417,4 +512,191 @@ mod tests {
|
||||
assert!(!only_claim.contains("### 规范纪律"), "空分区不得产生空小节");
|
||||
assert!(!only_claim.contains("冲突以"), "单文件不得注明双文件优先级");
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 知识路标子段(kb 句柄经 projection_section 显式注入,不经进程级全局:
|
||||
// 单测并发零串扰,append_projection 生产路径的句柄解析另由 app_handle 缺席态天然覆盖)
|
||||
// ============================================================
|
||||
|
||||
use df_storage::crud::{AiConversationRepo, ProjectRepo};
|
||||
use df_storage::models::{AiConversationRecord, KnowledgeRecord};
|
||||
|
||||
/// 知识条目 fixture(published,未验证,归属可选)
|
||||
fn kentry(id: &str, title: &str, reuse: i32, verified: bool, project: Option<&str>) -> KnowledgeRecord {
|
||||
KnowledgeRecord {
|
||||
id: id.to_string(),
|
||||
kind: "lesson".to_string(),
|
||||
title: title.to_string(),
|
||||
content: "正文".to_string(),
|
||||
tags: Some("[]".to_string()),
|
||||
status: "published".to_string(),
|
||||
confidence: None,
|
||||
reuse_count: reuse,
|
||||
verified,
|
||||
source_project: project.map(|p| p.to_string()),
|
||||
source_ref: None,
|
||||
reasoning: None,
|
||||
embedding_status: None,
|
||||
created_at: "1700000000000".to_string(),
|
||||
updated_at: "1700000000000".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 插入测试项目(同 knowledge_inject 测试口径)
|
||||
async fn ins_project(db: &Database, id: &str, path: &str) {
|
||||
ProjectRepo::new(db)
|
||||
.insert(df_storage::models::ProjectRecord {
|
||||
id: id.into(),
|
||||
name: "路标项目".into(),
|
||||
description: String::new(),
|
||||
status: df_types::types::ProjectStatus::Planning,
|
||||
idea_id: None,
|
||||
path: Some(path.into()),
|
||||
stack: None,
|
||||
archived: false,
|
||||
created_at: "2026-01-01".into(),
|
||||
updated_at: "2026-01-01".into(),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
/// 插入测试会话,带 boundary_json(dir_allowlist 单目录 → 级 a 归属解析用)
|
||||
async fn ins_conv_with_boundary(db: &Database, conv_id: &str, allow_dir: &str) {
|
||||
let boundary = crate::commands::ai::boundary::TaskBoundary {
|
||||
risk_ceiling: df_ai::ai_tools::RiskLevel::Medium,
|
||||
dir_allowlist: vec![allow_dir.to_string()],
|
||||
forbid_destructive: true,
|
||||
budget: None,
|
||||
};
|
||||
AiConversationRepo::new(db)
|
||||
.insert(AiConversationRecord {
|
||||
id: conv_id.into(),
|
||||
title: Some("t".into()),
|
||||
messages: "[]".into(),
|
||||
provider_id: None,
|
||||
model: None,
|
||||
models: None,
|
||||
archived: false,
|
||||
pinned: false,
|
||||
prompt_tokens: None,
|
||||
completion_tokens: None,
|
||||
pinned_goals: None,
|
||||
pending_approvals: None,
|
||||
created_at: "2026-01-01".into(),
|
||||
updated_at: "2026-01-01".into(),
|
||||
origin: None,
|
||||
boundary_json: Some(serde_json::to_string(&boundary).unwrap()),
|
||||
loop_state: None,
|
||||
status: "active".into(),
|
||||
working_context_json: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
async fn memory_kb() -> Arc<Database> {
|
||||
Arc::new(Database::open_in_memory().await.expect("内存库"))
|
||||
}
|
||||
|
||||
// ── 空库零回归:注入空库句柄,投影段与无库时逐字一致 ──
|
||||
#[tokio::test]
|
||||
async fn empty_kb_zero_regression() {
|
||||
let db = memory_kb().await;
|
||||
let dir = temp_project_dir();
|
||||
// 无规则文件 + 空库 → None(prompt 逐字不变)
|
||||
let none = projection_section(Some(dir.to_str().unwrap()), &format!("conv-{}", unique_tag()), Some(db.clone())).await;
|
||||
assert_eq!(none, None, "空库且无规则文件,投影段必须缺席");
|
||||
// 有规则文件 + 空库 → 规则照常注入,路标子块零出现(其余行为零变化)
|
||||
write_file(&dir, "CLAUDE.md", "- 规则条目");
|
||||
let rules_only = projection_section(Some(dir.to_str().unwrap()), &format!("conv-{}", unique_tag()), Some(db)).await;
|
||||
let sec = rules_only.expect("有规则文件必有段");
|
||||
assert!(sec.contains("### 项目规范(CLAUDE.md)"));
|
||||
assert!(!sec.contains("知识库路标"), "空库不得出现路标子段");
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
// ── 入选过滤:verified 或复用达标才入段,渲染为紧凑行 + 段首说明 + 段尾固定提示 ──
|
||||
#[tokio::test]
|
||||
async fn waymark_selects_verified_or_reused() {
|
||||
let db = memory_kb().await;
|
||||
let repo = KnowledgeRepo::new(&db);
|
||||
repo.insert(kentry("ok-v", "已验证经验", 0, true, None)).await.unwrap();
|
||||
repo.insert(kentry("ok-r", "复用达标经验", 3, false, None)).await.unwrap();
|
||||
repo.insert(kentry("no-r", "复用不足应淘汰", 2, false, None)).await.unwrap();
|
||||
let mut cand = kentry("no-c", "候选未发布应淘汰", 99, false, None);
|
||||
cand.status = "candidate".to_string();
|
||||
repo.insert(cand).await.unwrap();
|
||||
|
||||
let sec = projection_section(None, &format!("conv-{}", unique_tag()), Some(db))
|
||||
.await
|
||||
.expect("有合格条目必有段");
|
||||
assert!(sec.starts_with("## 项目认知投影"), "仅路标在场时须独占投影段");
|
||||
assert!(sec.contains("项目知识库路标(详情用 knowledge_search 按标题关键词检索)"), "段首说明行");
|
||||
assert!(sec.contains("\n- 已验证经验 | 复用0次"), "verified 豁免复用门槛入段");
|
||||
assert!(sec.contains("\n- 复用达标经验 | 复用3次"), "复用压线达标入段");
|
||||
assert!(!sec.contains("复用不足应淘汰"), "复用不足不入段");
|
||||
assert!(!sec.contains("候选未发布应淘汰"), "非 published 不入段");
|
||||
assert!(
|
||||
sec.ends_with("上述经验条目可直接复用;遇到工具故障/重复失败先检索路标"),
|
||||
"段尾固定提示行"
|
||||
);
|
||||
}
|
||||
|
||||
// ── 项目优先:boundary 解析出归属项目后,项目档在前,跨项目补充档按复用降序 ──
|
||||
#[tokio::test]
|
||||
async fn waymark_project_tier_first() {
|
||||
let db = memory_kb().await;
|
||||
ins_project(&db, "p-way", "e:/wk-lab/waymark-proj").await;
|
||||
ins_conv_with_boundary(&db, "conv-way", "e:/wk-lab/waymark-proj").await;
|
||||
let repo = KnowledgeRepo::new(&db);
|
||||
repo.insert(kentry("same1", "同项目条目", 5, false, Some("p-way"))).await.unwrap();
|
||||
repo.insert(kentry("foreign", "异项目高复用条目", 99, false, Some("p-other"))).await.unwrap();
|
||||
repo.insert(kentry("global1", "全局无归属条目", 10, false, None)).await.unwrap();
|
||||
|
||||
let sec = projection_section(None, "conv-way", Some(db)).await.expect("必有段");
|
||||
let same = sec.find("同项目条目").expect("同项目条目须入项目档");
|
||||
let global = sec.find("全局无归属条目").expect("全局条目须入补充档");
|
||||
let foreign = sec.find("异项目高复用条目").expect("异项目条目须入补充档");
|
||||
assert!(
|
||||
same < foreign && foreign < global,
|
||||
"项目档在前;补充档内复用降序(异项目99 先于全局10)"
|
||||
);
|
||||
}
|
||||
|
||||
// ── 会话缓存:同会话复用首查结果,库内追加不影响;新会话查到新条目 ──
|
||||
#[tokio::test]
|
||||
async fn waymark_cached_per_conversation() {
|
||||
let db = memory_kb().await;
|
||||
let repo = KnowledgeRepo::new(&db);
|
||||
repo.insert(kentry("a1", "首批经验", 3, false, None)).await.unwrap();
|
||||
let conv = format!("conv-{}", unique_tag());
|
||||
|
||||
let first = projection_section(None, &conv, Some(db.clone())).await.expect("必有段");
|
||||
assert!(first.contains("首批经验"));
|
||||
|
||||
repo.insert(kentry("b2", "次批经验", 3, false, None)).await.unwrap();
|
||||
let again = projection_section(None, &conv, Some(db.clone())).await.expect("必有段");
|
||||
assert_eq!(first, again, "同会话必须复用缓存(库内追加不影响)");
|
||||
assert!(!again.contains("次批经验"));
|
||||
|
||||
let fresh = projection_section(None, &format!("{conv}-new"), Some(db)).await.expect("必有段");
|
||||
assert!(fresh.contains("次批经验"), "新会话须重查库拿到新条目");
|
||||
}
|
||||
|
||||
// ── 渲染纯函数:标题压平换行 + 截断注省略号 + 空白标题跳过 ──
|
||||
#[test]
|
||||
fn render_waymark_flattens_and_caps_title() {
|
||||
let mut long = kentry("long", "", 7, false, None);
|
||||
long.title = format!("长{}标题", "词".repeat(60)); // 63 字,超 60 上限
|
||||
let mut multiline = kentry("multi", "含\n换行\t与制表", 2, true, None);
|
||||
multiline.verified = true;
|
||||
let blank = kentry("blank", " \n ", 1, true, None);
|
||||
let out = render_waymark(&[long, multiline, blank]);
|
||||
let line = out.lines().find(|l| l.starts_with("- 长")).expect("截断标题行在场");
|
||||
assert!(line.ends_with("… | 复用7次"), "超长标题须截断注省略号");
|
||||
assert!(line.matches('词').count() <= WAYMARK_TITLE_MAX_CHARS, "标题不得超上限");
|
||||
assert!(out.contains("- 含 换行 与制表 | 复用2次"), "换行/制表压平为单空格");
|
||||
assert!(!out.contains("- |"), "空白标题条目须跳过");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user