Files
2026-07-12 21:26:08 +08:00

1755 lines
70 KiB
Bash
Executable File
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/bin/bash
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
fail() {
echo "FAIL: $1" >&2
exit 1
}
assert_file_contains() {
local file="$1"
local text="$2"
if ! grep -F -- "$text" "$file" > /dev/null; then
fail "Expected $file to contain: $text"
fi
}
assert_file_not_contains() {
local file="$1"
local text="$2"
if grep -F -- "$text" "$file" > /dev/null; then
fail "Expected $file to not contain: $text"
fi
}
assert_text_contains() {
local text="$1"
local expected="$2"
if ! printf '%s' "$text" | grep -F -- "$expected" > /dev/null; then
fail "Expected output to contain: $expected"
fi
}
assert_text_not_contains() {
local text="$1"
local unexpected="$2"
if printf '%s' "$text" | grep -F -- "$unexpected" > /dev/null; then
fail "Expected output to not contain: $unexpected"
fi
}
assert_path_exists() {
local path="$1"
[ -e "$path" ] || fail "Expected path to exist: $path"
}
each_registry_label() {
local category="$1"
bash "$REPO_ROOT/scripts/source-registry.sh" list-by-category "$category" \
| awk -F '\t' 'NF { print $2 }'
}
assert_registry_labels_present_in_text() {
local text="$1"
local category="$2"
local label
while IFS= read -r label; do
[ -n "$label" ] || continue
assert_text_contains "$text" "$label"
done <<EOF
$(each_registry_label "$category")
EOF
}
assert_registry_labels_present_in_file() {
local file="$1"
local category="$2"
local label
while IFS= read -r label; do
[ -n "$label" ] || continue
assert_file_contains "$file" "$label"
done <<EOF
$(each_registry_label "$category")
EOF
}
make_stub() {
local path="$1"
local body="$2"
printf '%s\n' "$body" > "$path"
chmod +x "$path"
}
make_path_without_python() {
local bin_dir="$1"
local cmd cmd_path
mkdir -p "$bin_dir"
for cmd in bash dirname pwd mkdir tr awk sed sort cut head tail find wc uname date printf grep cat rm cp chmod mktemp; do
cmd_path="$(PATH="/usr/bin:/bin:/usr/sbin:/sbin" command -v "$cmd" 2>/dev/null || true)"
[ -n "$cmd_path" ] && ln -s "$cmd_path" "$bin_dir/$cmd"
done
}
make_repo_copy_without_git() {
local dest="$1"
cp -R "$REPO_ROOT" "$dest"
rm -rf "$dest/.git"
}
make_legacy_wiki() {
local wiki_root="$1"
mkdir -p "$wiki_root"/raw/{articles,tweets,wechat,pdfs,notes,assets}
mkdir -p "$wiki_root"/wiki/{entities,topics,sources,comparisons,synthesis}
cat > "$wiki_root/.wiki-schema.md" <<'EOF'
# Wiki Schema(知识库配置规范)
- 主题:旧知识库
- 创建日期:2026-04-01
EOF
printf '# 索引\n' > "$wiki_root/index.md"
printf '# 日志\n' > "$wiki_root/log.md"
printf '# 总览\n' > "$wiki_root/wiki/overview.md"
}
test_setup_runs_on_bash_3_2() {
local tmp_dir output
tmp_dir="$(mktemp -d)"
trap 'rm -rf "$tmp_dir"' RETURN
mkdir -p "$tmp_dir/home/.claude/skills" "$tmp_dir/bin"
make_stub "$tmp_dir/bin/bun" "#!/bin/sh
printf '%s\n' bun >> \"$tmp_dir/tool.log\"
mkdir -p node_modules
exit 0"
make_stub "$tmp_dir/bin/lsof" '#!/bin/sh
exit 1'
make_stub "$tmp_dir/bin/uv" "#!/bin/sh
printf '%s\n' uv >> \"$tmp_dir/tool.log\"
exit 0"
output="$(
HOME="$tmp_dir/home" \
PATH="$tmp_dir/bin:/usr/bin:/bin:/usr/sbin:/sbin" \
bash "$REPO_ROOT/setup.sh" 2>&1
)" || fail "setup.sh should run successfully under bash 3.2"
assert_path_exists "$tmp_dir/home/.claude/skills/llm-wiki/SKILL.md"
assert_path_exists "$tmp_dir/home/.claude/skills/llm-wiki-upgrade/SKILL.md"
assert_path_exists "$tmp_dir/home/.claude/skills/llm-wiki/deps/baoyu-url-to-markdown"
[ ! -d "$tmp_dir/home/.claude/skills/baoyu-url-to-markdown" ] || fail "Did not expect optional adapters to be enabled by default"
[ ! -f "$tmp_dir/tool.log" ] || fail "Did not expect bun or uv to run for core-only setup"
assert_text_contains "$output" "当前只准备了知识库核心主线"
assert_text_contains "$output" "--with-optional-adapters"
assert_text_contains "$output" "/llm-wiki-upgrade"
}
test_install_with_optional_adapters_bootstraps_dependencies() {
local tmp_dir output
tmp_dir="$(mktemp -d)"
trap 'rm -rf "$tmp_dir"' RETURN
mkdir -p "$tmp_dir/home/.claude/skills" "$tmp_dir/bin"
make_stub "$tmp_dir/bin/bun" '#!/bin/sh
mkdir -p node_modules
exit 0'
make_stub "$tmp_dir/bin/lsof" '#!/bin/sh
exit 1'
make_stub "$tmp_dir/bin/uv" "#!/bin/sh
printf '%s\n' \"\$*\" >> \"$tmp_dir/uv.log\"
printf '%s\n' '#!/bin/sh' 'exit 0' > \"$tmp_dir/bin/wechat-article-to-markdown\"
chmod +x \"$tmp_dir/bin/wechat-article-to-markdown\"
exit 0"
output="$(
HOME="$tmp_dir/home" \
PATH="$tmp_dir/bin:/usr/bin:/bin:/usr/sbin:/sbin" \
bash "$REPO_ROOT/install.sh" --platform claude --with-optional-adapters 2>&1
)" || fail "install.sh should support explicit optional adapter bootstrap"
[ -d "$tmp_dir/home/.claude/skills/baoyu-url-to-markdown" ] || fail "Expected baoyu-url-to-markdown to be installed when explicitly enabled"
[ -d "$tmp_dir/home/.claude/skills/youtube-transcript" ] || fail "Expected youtube-transcript to be installed when explicitly enabled"
assert_path_exists "$tmp_dir/bin/wechat-article-to-markdown"
assert_file_contains "$tmp_dir/uv.log" "tool install git+https://github.com/jackwener/wechat-article-to-markdown.git"
assert_text_contains "$output" "未检测到 Chrome 调试端口 9222"
assert_text_contains "$output" "仍可自动拉起临时浏览器"
assert_text_contains "$output" "open -na \"Google Chrome\" --args --remote-debugging-port=9222"
assert_text_contains "$output" "wechat-article-to-markdown 安装完成"
}
test_install_dry_run_for_claude() {
local tmp_dir output
tmp_dir="$(mktemp -d)"
trap 'rm -rf "$tmp_dir"' RETURN
mkdir -p "$tmp_dir/home/.claude/skills"
output="$(
HOME="$tmp_dir/home" \
bash "$REPO_ROOT/install.sh" --platform claude --dry-run 2>&1
)" || fail "install.sh dry-run for Claude should succeed"
assert_text_contains "$output" "平台:claude"
assert_text_contains "$output" "$tmp_dir/home/.claude/skills/llm-wiki"
assert_text_contains "$output" "--with-optional-adapters"
assert_text_not_contains "$output" "uv tool install"
assert_text_not_contains "$output" "bun install"
}
test_install_help_does_not_require_python() {
local tmp_dir output
tmp_dir="$(mktemp -d)"
trap 'rm -rf "$tmp_dir"' RETURN
make_path_without_python "$tmp_dir/bin"
output="$(
PATH="$tmp_dir/bin" \
bash "$REPO_ROOT/install.sh" --help 2>&1
)" || fail "install.sh --help should not require Python"
assert_text_contains "$output" "用法:"
}
test_install_dry_run_does_not_require_python() {
local tmp_dir output
tmp_dir="$(mktemp -d)"
trap 'rm -rf "$tmp_dir"' RETURN
mkdir -p "$tmp_dir/home/.codex/skills"
make_path_without_python "$tmp_dir/bin"
output="$(
HOME="$tmp_dir/home" \
PATH="$tmp_dir/bin" \
bash "$REPO_ROOT/install.sh" --platform codex --dry-run 2>&1
)" || fail "install.sh dry-run should not require Python"
assert_text_contains "$output" "install.ps1"
assert_text_contains "$output" "llm-wiki 已准备完成"
}
test_install_auto_refuses_ambiguous_platforms() {
local tmp_dir output
tmp_dir="$(mktemp -d)"
trap 'rm -rf "$tmp_dir"' RETURN
mkdir -p "$tmp_dir/home/.claude/skills" "$tmp_dir/home/.codex/skills" "$tmp_dir/home/.hermes/skills"
if output="$(
HOME="$tmp_dir/home" \
bash "$REPO_ROOT/install.sh" --platform auto 2>&1
)"; then
fail "install.sh auto should fail when multiple platform homes are present"
fi
assert_text_contains "$output" "检测到多个可用平台"
assert_text_contains "$output" "--platform"
}
test_install_openclaw_copies_bundle() {
local tmp_dir
tmp_dir="$(mktemp -d)"
trap 'rm -rf "$tmp_dir"' RETURN
mkdir -p "$tmp_dir/home/.openclaw/skills" "$tmp_dir/bin"
make_stub "$tmp_dir/bin/bun" '#!/bin/sh
mkdir -p node_modules
exit 0'
make_stub "$tmp_dir/bin/lsof" '#!/bin/sh
exit 1'
HOME="$tmp_dir/home" \
PATH="$tmp_dir/bin:/usr/bin:/bin:/usr/sbin:/sbin" \
bash "$REPO_ROOT/install.sh" --platform openclaw > /dev/null 2>&1 || fail "install.sh should install for OpenClaw"
assert_path_exists "$tmp_dir/home/.openclaw/skills/llm-wiki/SKILL.md"
assert_path_exists "$tmp_dir/home/.openclaw/skills/llm-wiki/install.sh"
assert_path_exists "$tmp_dir/home/.openclaw/skills/llm-wiki/scripts/source-registry.sh"
assert_path_exists "$tmp_dir/home/.openclaw/skills/llm-wiki/deps/baoyu-url-to-markdown"
[ ! -d "$tmp_dir/home/.openclaw/skills/llm-wiki-upgrade" ] || fail "Did not expect Claude-only companion skill to be installed for OpenClaw"
[ ! -d "$tmp_dir/home/.openclaw/skills/baoyu-url-to-markdown" ] || fail "Did not expect optional adapters to be enabled by default"
}
test_install_hermes_copies_bundle() {
local tmp_dir
tmp_dir="$(mktemp -d)"
trap 'rm -rf "$tmp_dir"' RETURN
mkdir -p "$tmp_dir/home/.hermes/skills" "$tmp_dir/bin"
make_stub "$tmp_dir/bin/bun" '#!/bin/sh
mkdir -p node_modules
exit 0'
make_stub "$tmp_dir/bin/lsof" '#!/bin/sh
exit 1'
HOME="$tmp_dir/home" \
PATH="$tmp_dir/bin:/usr/bin:/bin:/usr/sbin:/sbin" \
bash "$REPO_ROOT/install.sh" --platform hermes > /dev/null 2>&1 || fail "install.sh should install for Hermes"
assert_path_exists "$tmp_dir/home/.hermes/skills/llm-wiki/SKILL.md"
assert_path_exists "$tmp_dir/home/.hermes/skills/llm-wiki/HERMES.md"
assert_path_exists "$tmp_dir/home/.hermes/skills/llm-wiki/platforms/hermes/README.md"
assert_path_exists "$tmp_dir/home/.hermes/skills/llm-wiki/scripts/source-registry.sh"
[ ! -d "$tmp_dir/home/.hermes/skills/baoyu-url-to-markdown" ] || fail "Did not expect optional adapters to be enabled by default"
}
test_upgrade_refreshes_claude_companion_skill() {
local tmp_dir output repo_copy
tmp_dir="$(mktemp -d)"
trap 'rm -rf "$tmp_dir"' RETURN
repo_copy="$tmp_dir/repo"
make_repo_copy_without_git "$repo_copy"
mkdir -p "$tmp_dir/home/.claude/skills/llm-wiki" "$tmp_dir/home/.claude/skills/llm-wiki-upgrade"
printf '# Changelog\n\n## v2.3.0 (2026-04-15)\n' > "$tmp_dir/home/.claude/skills/llm-wiki/CHANGELOG.md"
printf 'old\n' > "$tmp_dir/home/.claude/skills/llm-wiki-upgrade/SKILL.md"
output="$(
HOME="$tmp_dir/home" \
bash "$repo_copy/install.sh" --upgrade --platform claude 2>&1
)" || fail "install.sh --upgrade should refresh the Claude companion skill"
assert_text_contains "$output" "/llm-wiki-upgrade"
assert_path_exists "$tmp_dir/home/.claude/skills/llm-wiki-upgrade/SKILL.md"
assert_file_contains "$tmp_dir/home/.claude/skills/llm-wiki-upgrade/SKILL.md" "--with-optional-adapters"
assert_file_contains "$tmp_dir/home/.claude/skills/llm-wiki-upgrade/SKILL.md" 'bash "$TMP_DIR/llm-wiki-skill/install.sh" --upgrade --platform claude'
}
test_init_fills_language_placeholder() {
local tmp_dir wiki_root
tmp_dir="$(mktemp -d)"
trap 'rm -rf "$tmp_dir"' RETURN
wiki_root="$tmp_dir/Test Wiki"
bash "$REPO_ROOT/scripts/init-wiki.sh" "$wiki_root" "测试主题" "English" > /dev/null
assert_file_contains "$wiki_root/.wiki-schema.md" "- 语言:English"
assert_file_not_contains "$wiki_root/.wiki-schema.md" "{{LANGUAGE}}"
}
test_phase1_templates_exist() {
assert_path_exists "$REPO_ROOT/templates/purpose-template.md"
assert_path_exists "$REPO_ROOT/templates/purpose-en-template.md"
assert_path_exists "$REPO_ROOT/templates/query-template.md"
assert_file_contains "$REPO_ROOT/templates/purpose-template.md" "# 研究目的与方向"
assert_file_contains "$REPO_ROOT/templates/purpose-template.md" "## 核心目标"
assert_file_contains "$REPO_ROOT/templates/purpose-en-template.md" "# Research Purpose and Direction"
assert_file_contains "$REPO_ROOT/templates/purpose-en-template.md" "## Core Goal"
assert_file_contains "$REPO_ROOT/templates/query-template.md" "type: query"
assert_file_contains "$REPO_ROOT/templates/query-template.md" "derived: true"
}
test_init_creates_purpose_and_cache_files() {
local tmp_dir wiki_root
tmp_dir="$(mktemp -d)"
trap 'rm -rf "$tmp_dir"' RETURN
wiki_root="$tmp_dir/English Wiki"
bash "$REPO_ROOT/scripts/init-wiki.sh" "$wiki_root" "测试主题" "English" > /dev/null
assert_path_exists "$wiki_root/purpose.md"
assert_path_exists "$wiki_root/.wiki-cache.json"
assert_file_contains "$wiki_root/purpose.md" "# Research Purpose and Direction"
assert_file_contains "$wiki_root/.wiki-cache.json" '"version": 1'
assert_file_contains "$wiki_root/.wiki-cache.json" '"entries": {}'
}
test_cache_script_handles_miss_hit_and_invalidate() {
local tmp_dir wiki_root file_path output
tmp_dir="$(mktemp -d)"
trap 'rm -rf "$tmp_dir"' RETURN
wiki_root="$tmp_dir/cache-wiki"
bash "$REPO_ROOT/scripts/init-wiki.sh" "$wiki_root" "缓存测试" "中文" > /dev/null
file_path="$wiki_root/raw/articles/example.md"
printf '缓存测试内容\n' > "$file_path"
output="$(
bash "$REPO_ROOT/scripts/cache.sh" check "$file_path" 2>&1
)" || fail "cache.sh check should work for uncached files"
[ "$output" = "MISS:no_entry" ] || fail "Expected initial cache check to be MISS:no_entry, got: $output"
# source 页面必须有 source_path frontmatter,自愈才能验证通过
cat > "$wiki_root/wiki/sources/example.md" <<'SRCEOF'
---
source_path: raw/articles/example.md
---
# 来源页
SRCEOF
bash "$REPO_ROOT/scripts/cache.sh" update "$file_path" "wiki/sources/example.md" > /dev/null 2>&1 \
|| fail "cache.sh update should succeed"
output="$(
bash "$REPO_ROOT/scripts/cache.sh" check "$file_path" 2>&1
)" || fail "cache.sh check should work for cached files"
[ "$output" = "HIT" ] || fail "Expected updated cache check to be HIT, got: $output"
bash "$REPO_ROOT/scripts/cache.sh" invalidate "$file_path" > /dev/null 2>&1 \
|| fail "cache.sh invalidate should succeed"
output="$(
bash "$REPO_ROOT/scripts/cache.sh" check "$file_path" 2>&1
)" || fail "cache.sh check should work after invalidation"
# 自愈:invalidate 后 source 页面仍存在,stem + source_path 匹配 → HIT(repaired)
[ "$output" = "HIT(repaired)" ] || fail "Expected invalidated cache check to be HIT(repaired), got: $output"
}
test_skill_md_phase2_init_mentions_purpose_and_cache() {
local section
section="$(sed -n '/## 工作流 1init/,/## 工作流 2ingest/p' "$REPO_ROOT/SKILL.md")"
assert_text_contains "$section" "purpose.md"
assert_text_contains "$section" ".wiki-cache.json"
assert_text_contains "$section" "填写核心目标和关键问题"
}
test_skill_md_phase2_ingest_mentions_two_step_cache_and_confidence() {
local section
section="$(sed -n '/## 工作流 2ingest/,/## 工作流 3batch-ingest/p' "$REPO_ROOT/SKILL.md")"
assert_text_contains "$section" '`purpose.md` > `.wiki-schema.md` > `index.md`'
assert_text_contains "$section" 'bash ${SKILL_DIR}/scripts/cache.sh check'
assert_text_contains "$section" "Step 1:结构化分析"
assert_text_contains "$section" "Step 2:页面生成"
assert_text_contains "$section" '"confidence": "EXTRACTED"'
assert_text_contains "$section" "<!-- confidence: UNVERIFIED -->"
assert_text_contains "$section" "页面顶部加注释说明本次处理因格式问题降级"
}
test_skill_md_phase2_batch_ingest_mentions_cache_skip_summary() {
local section
section="$(sed -n '/## 工作流 3batch-ingest/,/## 工作流 4query/p' "$REPO_ROOT/SKILL.md")"
assert_text_contains "$section" '每个文件先 `cache check`'
assert_text_contains "$section" "已跳过 N 个(无变化),处理 M 个(新增/更新)"
}
test_skill_md_phase2_status_mentions_purpose_presence() {
local section
section="$(sed -n '/## 工作流 6status/,/## 工作流 7digest/p' "$REPO_ROOT/SKILL.md")"
assert_text_contains "$section" "purpose.md 是否存在"
}
test_skill_md_phase2_has_delete_workflow_and_route() {
local route_section delete_section
route_section="$(sed -n '/## 工作流路由/,/## 通用前置检查/p' "$REPO_ROOT/SKILL.md")"
delete_section="$(sed -n '/## 工作流 9delete/,$p' "$REPO_ROOT/SKILL.md")"
assert_text_contains "$route_section" '"删除素材"、"remove"、"delete source"、"移除"'
assert_text_contains "$route_section" "→ **delete**"
assert_text_contains "$delete_section" "影响超过 5 个页面时"
assert_text_contains "$delete_section" 'bash ${SKILL_DIR}/scripts/delete-helper.sh scan-refs'
assert_text_contains "$delete_section" "cache.sh invalidate"
}
test_delete_helper_scans_reference_files() {
local tmp_dir wiki_root output
tmp_dir="$(mktemp -d)"
trap 'rm -rf "$tmp_dir"' RETURN
wiki_root="$tmp_dir/delete-wiki"
mkdir -p "$wiki_root"/raw/articles
mkdir -p "$wiki_root"/wiki/{sources,entities,topics}
printf '原文\n' > "$wiki_root/raw/articles/2024-01-15-ai-article.md"
cat > "$wiki_root/wiki/sources/2024-01-15-ai-article.md" <<'EOF'
---
sources: ["raw/articles/2024-01-15-ai-article.md"]
---
[source: AI 文章](../raw/articles/2024-01-15-ai-article.md)
EOF
printf '见 raw/articles/2024-01-15-ai-article.md\n' > "$wiki_root/wiki/entities/AI-Agent.md"
printf '引用 [source: AI 文章](../raw/articles/2024-01-15-ai-article.md)\n' > "$wiki_root/wiki/topics/大语言模型.md"
output="$(
bash "$REPO_ROOT/scripts/delete-helper.sh" scan-refs "$wiki_root" "2024-01-15-ai-article.md" 2>&1
)" || fail "delete-helper scan-refs should succeed"
assert_text_contains "$output" "wiki/entities/AI-Agent.md"
assert_text_contains "$output" "wiki/sources/2024-01-15-ai-article.md"
assert_text_contains "$output" "wiki/topics/大语言模型.md"
}
test_skill_md_phase3_query_mentions_persistence_and_duplicate_handling() {
local section
section="$(sed -n '/## 工作流 4query/,/## 工作流 5lint/p' "$REPO_ROOT/SKILL.md")"
assert_text_contains "$section" "wiki/queries/{date}-{short-hash}.md"
assert_text_contains "$section" "derived: true"
assert_text_contains "$section" "引用了 3 个及以上来源"
assert_text_contains "$section" "通过 frontmatter tags 和 title 匹配"
assert_text_contains "$section" "superseded-by"
assert_text_contains "$section" "不作为主要知识来源"
}
test_hook_session_start_outputs_context_when_wiki_exists() {
local tmp_dir output wiki_root
tmp_dir="$(mktemp -d)"
trap 'rm -rf "$tmp_dir"' RETURN
wiki_root="$tmp_dir/my-wiki"
mkdir -p "$tmp_dir/home" "$wiki_root"
printf '# schema\n' > "$wiki_root/.wiki-schema.md"
printf '# index\n' > "$wiki_root/index.md"
printf '%s\n' "$wiki_root" > "$tmp_dir/home/.llm-wiki-path"
output="$(
HOME="$tmp_dir/home" \
bash "$REPO_ROOT/scripts/hook-session-start.sh" 2>&1
)" || fail "hook-session-start.sh should succeed when wiki exists"
assert_text_contains "$output" "hookSpecificOutput"
assert_text_contains "$output" "SessionStart"
assert_text_contains "$output" "[llm-wiki] 检测到知识库"
}
test_hook_session_start_returns_empty_json_without_wiki() {
local tmp_dir output
tmp_dir="$(mktemp -d)"
trap 'rm -rf "$tmp_dir"' RETURN
mkdir -p "$tmp_dir/home"
output="$(
HOME="$tmp_dir/home" \
bash "$REPO_ROOT/scripts/hook-session-start.sh" 2>&1
)" || fail "hook-session-start.sh should succeed without wiki"
[ "$output" = "{}" ] || fail "Expected hook-session-start.sh to return {} without wiki"
}
test_hook_session_start_without_wiki_does_not_require_python() {
local tmp_dir output
tmp_dir="$(mktemp -d)"
trap 'rm -rf "$tmp_dir"' RETURN
mkdir -p "$tmp_dir/home"
make_path_without_python "$tmp_dir/bin"
output="$(
HOME="$tmp_dir/home" \
PATH="$tmp_dir/bin" \
bash "$REPO_ROOT/scripts/hook-session-start.sh" 2>&1
)" || fail "hook-session-start.sh should return {} without Python when no wiki exists"
[ "$output" = "{}" ] || fail "Expected hook-session-start.sh to return {} without wiki, got: $output"
}
test_install_registers_and_uninstalls_session_start_hook() {
local tmp_dir settings_path output
tmp_dir="$(mktemp -d)"
trap 'rm -rf "$tmp_dir"' RETURN
mkdir -p "$tmp_dir/home/.claude/skills" "$tmp_dir/bin"
settings_path="$tmp_dir/home/.claude/settings.json"
cat > "$settings_path" <<'EOF'
{
"enabledPlugins": {
"demo": true
}
}
EOF
make_stub "$tmp_dir/bin/bun" '#!/bin/sh
mkdir -p node_modules
exit 0'
make_stub "$tmp_dir/bin/lsof" '#!/bin/sh
exit 1'
make_stub "$tmp_dir/bin/uv" '#!/bin/sh
printf "%s\n" "#!/bin/sh" "exit 0" > "'"$tmp_dir"'/bin/wechat-article-to-markdown"
chmod +x "'"$tmp_dir"'/bin/wechat-article-to-markdown"
exit 0'
HOME="$tmp_dir/home" \
PATH="$tmp_dir/bin:/usr/bin:/bin:/usr/sbin:/sbin" \
bash "$REPO_ROOT/install.sh" --platform claude --install-hooks > /dev/null 2>&1 \
|| fail "install.sh should register session hook for Claude"
assert_path_exists "$tmp_dir/home/.claude/settings.json.bak.llm-wiki"
assert_file_contains "$settings_path" '"SessionStart"'
assert_file_contains "$settings_path" "$tmp_dir/home/.claude/skills/llm-wiki/scripts/hook-session-start.sh"
HOME="$tmp_dir/home" \
PATH="$tmp_dir/bin:/usr/bin:/bin:/usr/sbin:/sbin" \
bash "$REPO_ROOT/install.sh" --uninstall-hooks > /dev/null 2>&1 \
|| fail "install.sh should remove session hook"
assert_file_not_contains "$settings_path" "hook-session-start.sh"
assert_file_contains "$settings_path" '"enabledPlugins"'
}
test_platform_entries_mention_hook_and_wiki_context() {
assert_file_contains "$REPO_ROOT/platforms/claude/CLAUDE.md" "/llm-wiki-upgrade"
assert_file_contains "$REPO_ROOT/platforms/claude/CLAUDE.md" "--install-hooks"
assert_file_contains "$REPO_ROOT/platforms/codex/AGENTS.md" "优先查阅 wiki/index.md"
assert_file_contains "$REPO_ROOT/platforms/openclaw/README.md" "--upgrade --platform openclaw --target-dir <你的技能目录>/llm-wiki"
assert_file_contains "$REPO_ROOT/platforms/hermes/README.md" "--platform hermes"
assert_file_contains "$REPO_ROOT/platforms/hermes/README.md" "~/.hermes/skills/llm-wiki"
}
test_root_entries_explain_core_only_optional_and_target_dir() {
assert_file_contains "$REPO_ROOT/AGENTS.md" "--with-optional-adapters"
assert_file_contains "$REPO_ROOT/AGENTS.md" "默认只准备知识库核心主线"
assert_file_contains "$REPO_ROOT/AGENTS.md" "--target-dir <你的技能目录>/llm-wiki"
assert_file_contains "$REPO_ROOT/CLAUDE.md" "/llm-wiki-upgrade"
assert_file_contains "$REPO_ROOT/CLAUDE.md" "--with-optional-adapters"
assert_file_contains "$REPO_ROOT/CLAUDE.md" "默认只准备知识库核心主线"
assert_file_contains "$REPO_ROOT/HERMES.md" "--platform hermes"
assert_file_contains "$REPO_ROOT/HERMES.md" "~/.hermes/skills/llm-wiki"
}
test_skill_md_phase5_lint_mentions_confidence_audit() {
local section
section="$(sed -n '/## 工作流 5lint/,/## 工作流 6status/p' "$REPO_ROOT/SKILL.md")"
assert_text_contains "$section" "置信度报告"
assert_text_contains "$section" "AMBIGUOUS"
assert_text_contains "$section" "抽查标注为 EXTRACTED 的条目"
}
test_changelog_mentions_wiki_core_upgrades() {
assert_file_contains "$REPO_ROOT/CHANGELOG.md" "purpose.md"
assert_file_contains "$REPO_ROOT/CHANGELOG.md" "SessionStart hook"
assert_file_contains "$REPO_ROOT/CHANGELOG.md" "delete 工作流"
assert_file_contains "$REPO_ROOT/CHANGELOG.md" "/llm-wiki-upgrade"
assert_file_contains "$REPO_ROOT/CHANGELOG.md" "--with-optional-adapters"
assert_file_contains "$REPO_ROOT/CHANGELOG.md" "核心主线"
}
test_readme_sections() {
assert_file_contains "$REPO_ROOT/README.md" "## 30 秒上手"
assert_file_contains "$REPO_ROOT/README.md" "<summary><strong>常见问题</strong></summary>"
assert_file_contains "$REPO_ROOT/README.md" "bash install.sh --platform claude"
assert_file_contains "$REPO_ROOT/README.md" "bash install.sh --platform codex"
assert_file_contains "$REPO_ROOT/README.md" "bash install.sh --platform openclaw"
assert_file_contains "$REPO_ROOT/README.md" "bash install.sh --platform hermes"
assert_file_contains "$REPO_ROOT/README.md" "--with-optional-adapters"
assert_file_contains "$REPO_ROOT/README.md" "/llm-wiki-upgrade"
assert_file_contains "$REPO_ROOT/README.md" "--target-dir <你的技能目录>/llm-wiki"
assert_file_contains "$REPO_ROOT/README.md" "--upgrade --platform openclaw --target-dir <你的技能目录>/llm-wiki"
assert_file_contains "$REPO_ROOT/README.md" "--upgrade --platform hermes --target-dir <你的技能目录>/llm-wiki"
assert_file_contains "$REPO_ROOT/README.md" "wechat-article-to-markdown"
assert_file_contains "$REPO_ROOT/README.md" "HERMES.md"
assert_file_not_contains "$REPO_ROOT/README.md" "bash setup.sh"
assert_file_not_contains "$REPO_ROOT/README.md" "x-article-extractor"
assert_file_not_contains "$REPO_ROOT/README.md" "baoyu-danger-x-to-markdown"
}
test_claude_upgrade_companion_source_exists() {
assert_path_exists "$REPO_ROOT/platforms/claude/companions/llm-wiki-upgrade/SKILL.md"
assert_file_contains "$REPO_ROOT/platforms/claude/companions/llm-wiki-upgrade/SKILL.md" "--with-optional-adapters"
assert_file_contains "$REPO_ROOT/platforms/claude/companions/llm-wiki-upgrade/SKILL.md" 'bash "$TMP_DIR/llm-wiki-skill/install.sh" --upgrade --platform claude'
}
test_uv_tool_install_failure_is_graceful() {
local tmp_dir output
tmp_dir="$(mktemp -d)"
trap 'rm -rf "$tmp_dir"' RETURN
mkdir -p "$tmp_dir/home/.claude/skills" "$tmp_dir/bin"
make_stub "$tmp_dir/bin/bun" '#!/bin/sh
mkdir -p node_modules
exit 0'
make_stub "$tmp_dir/bin/lsof" '#!/bin/sh
exit 1'
make_stub "$tmp_dir/bin/uv" '#!/bin/sh
exit 1'
output="$(
HOME="$tmp_dir/home" \
PATH="$tmp_dir/bin:/usr/bin:/bin:/usr/sbin:/sbin" \
bash "$REPO_ROOT/install.sh" --platform claude --with-optional-adapters 2>&1
)" || fail "install.sh should keep going when optional adapter bootstrap fails"
assert_text_contains "$output" "wechat-article-to-markdown 安装失败"
assert_text_contains "$output" "llm-wiki 已准备完成"
assert_path_exists "$tmp_dir/home/.claude/skills/llm-wiki/SKILL.md"
}
test_upgrade_auto_refuses_ambiguous_installed_platforms() {
local tmp_dir output
tmp_dir="$(mktemp -d)"
trap 'rm -rf "$tmp_dir"' RETURN
mkdir -p "$tmp_dir/home/.claude/skills/llm-wiki" "$tmp_dir/home/.codex/skills/llm-wiki" "$tmp_dir/home/.hermes/skills/llm-wiki"
if output="$(
HOME="$tmp_dir/home" \
bash "$REPO_ROOT/install.sh" --upgrade --platform auto 2>&1
)"; then
fail "install.sh --upgrade --platform auto should fail when multiple installs exist"
fi
assert_text_contains "$output" "检测到多个已安装平台"
assert_text_contains "$output" "--platform"
}
test_upgrade_uses_explicit_target_dir() {
local tmp_dir output custom_target repo_copy
tmp_dir="$(mktemp -d)"
trap 'rm -rf "$tmp_dir"' RETURN
repo_copy="$tmp_dir/repo"
make_repo_copy_without_git "$repo_copy"
custom_target="$tmp_dir/custom/llm-wiki"
mkdir -p "$tmp_dir/home/.openclaw/skills" "$custom_target"
printf 'stale\n' > "$custom_target/README.md"
output="$(
HOME="$tmp_dir/home" \
bash "$repo_copy/install.sh" --upgrade --platform openclaw --target-dir "$custom_target" 2>&1
)" || fail "install.sh --upgrade should support explicit target directories"
assert_text_contains "$output" "目标目录:$custom_target"
assert_text_contains "$output" "llm-wiki 升级完成"
assert_path_exists "$custom_target/install.sh"
cmp -s "$custom_target/README.md" "$repo_copy/README.md" \
|| fail "Expected explicit target dir upgrade to refresh README.md"
[ ! -d "$tmp_dir/home/.openclaw/skills/llm-wiki" ] || fail "Did not expect upgrade to write into the default OpenClaw skill path"
}
test_upgrade_fails_when_explicit_target_dir_is_missing() {
local tmp_dir output custom_target repo_copy
tmp_dir="$(mktemp -d)"
trap 'rm -rf "$tmp_dir"' RETURN
repo_copy="$tmp_dir/repo"
make_repo_copy_without_git "$repo_copy"
custom_target="$tmp_dir/custom/llm-wiki"
mkdir -p "$tmp_dir/home/.openclaw/skills"
if output="$(
HOME="$tmp_dir/home" \
bash "$repo_copy/install.sh" --upgrade --platform openclaw --target-dir "$custom_target" 2>&1
)"; then
fail "install.sh --upgrade should fail when the explicit target directory is missing"
fi
assert_text_contains "$output" "尚未安装 llm-wiki"
assert_text_contains "$output" "升级失败"
}
test_skill_md_routes_wechat_to_new_tool() {
assert_file_contains "$REPO_ROOT/SKILL.md" "scripts/source-registry.sh match-url"
assert_file_contains "$REPO_ROOT/SKILL.md" "scripts/source-registry.sh match-file"
assert_file_contains "$REPO_ROOT/SKILL.md" '`adapter_name`'
assert_file_not_contains "$REPO_ROOT/SKILL.md" "x-article-extractor"
}
test_templates_have_no_empty_links() {
assert_file_not_contains "$REPO_ROOT/templates/entity-template.md" "- [[]]"
assert_file_not_contains "$REPO_ROOT/templates/source-template.md" "- [[]]"
assert_file_not_contains "$REPO_ROOT/templates/topic-template.md" "- [[]]"
}
test_batch_ingest_has_step_two() {
local section
section="$(sed -n '/## 工作流 3batch-ingest/,/## 工作流 4query/p' "$REPO_ROOT/SKILL.md")"
assert_text_contains "$section" "1. **确认知识库路径**"
assert_text_contains "$section" "2. **列出所有可处理文件**"
assert_text_contains "$section" "3. **展示文件列表**"
}
test_english_templates_exist_and_have_placeholders() {
assert_path_exists "$REPO_ROOT/templates/index-en-template.md"
assert_path_exists "$REPO_ROOT/templates/overview-en-template.md"
assert_path_exists "$REPO_ROOT/templates/log-en-template.md"
assert_file_contains "$REPO_ROOT/templates/index-en-template.md" "{{DATE}}"
assert_file_contains "$REPO_ROOT/templates/index-en-template.md" "{{TOPIC}}"
assert_file_contains "$REPO_ROOT/templates/overview-en-template.md" "{{DATE}}"
assert_file_contains "$REPO_ROOT/templates/overview-en-template.md" "{{TOPIC}}"
assert_file_contains "$REPO_ROOT/templates/log-en-template.md" "{{DATE}}"
assert_file_contains "$REPO_ROOT/templates/log-en-template.md" "{{TOPIC}}"
}
test_english_templates_have_no_empty_links() {
assert_file_not_contains "$REPO_ROOT/templates/index-en-template.md" "[[]]"
assert_file_not_contains "$REPO_ROOT/templates/overview-en-template.md" "[[]]"
assert_file_not_contains "$REPO_ROOT/templates/log-en-template.md" "[[]]"
}
test_skill_md_has_shared_preflight_and_language_rules() {
assert_file_contains "$REPO_ROOT/SKILL.md" "## 通用前置检查"
assert_file_contains "$REPO_ROOT/SKILL.md" "## 输出语言规则"
assert_file_contains "$REPO_ROOT/SKILL.md" "素材 → Source"
assert_file_contains "$REPO_ROOT/SKILL.md" "知识图谱 → Knowledge Graph"
}
test_skill_md_uses_external_english_templates_and_no_english_output_blocks() {
assert_file_contains "$REPO_ROOT/SKILL.md" "templates/index-en-template.md"
assert_file_contains "$REPO_ROOT/SKILL.md" "templates/overview-en-template.md"
assert_file_contains "$REPO_ROOT/SKILL.md" "templates/log-en-template.md"
assert_file_not_contains "$REPO_ROOT/SKILL.md" "**Englishen**"
}
test_setup_wrapper_is_marked_deprecated() {
assert_file_contains "$REPO_ROOT/setup.sh" "已废弃:请使用 bash install.sh --platform claude"
}
test_source_registry_contract_is_frozen() {
local output
output="$(
bash "$REPO_ROOT/scripts/source-registry.sh" fields 2>&1
)" || fail "source-registry fields should be readable"
assert_text_contains "$output" "source_id"
assert_text_contains "$output" "source_label"
assert_text_contains "$output" "source_category"
assert_text_contains "$output" "input_mode"
assert_text_contains "$output" "raw_dir"
assert_text_contains "$output" "original_ref"
assert_text_contains "$output" "ingest_text"
assert_text_contains "$output" "adapter_name"
assert_text_contains "$output" "fallback_hint"
}
test_source_registry_groups_core_optional_and_manual_sources() {
local output
output="$(
bash "$REPO_ROOT/scripts/source-registry.sh" list 2>&1
)" || fail "source-registry list should be readable"
assert_text_contains "$output" "core_builtin"
assert_text_contains "$output" "optional_adapter"
assert_text_contains "$output" "manual_only"
assert_text_contains "$output" "local_pdf"
assert_text_contains "$output" "plain_text"
assert_text_contains "$output" "web_article"
assert_text_contains "$output" "wechat_article"
assert_text_contains "$output" "xiaohongshu_post"
}
test_source_registry_exposes_install_dependency_groups() {
local bundled_output install_time_output
bundled_output="$(
bash "$REPO_ROOT/scripts/source-registry.sh" unique-dependencies bundled 2>&1
)" || fail "source-registry should list bundled dependencies"
install_time_output="$(
bash "$REPO_ROOT/scripts/source-registry.sh" unique-dependencies install_time 2>&1
)" || fail "source-registry should list install-time dependencies"
assert_text_contains "$bundled_output" "baoyu-url-to-markdown"
assert_text_contains "$bundled_output" "youtube-transcript"
assert_text_contains "$install_time_output" "wechat-article-to-markdown"
}
test_source_registry_validation_passes() {
bash "$REPO_ROOT/scripts/source-registry.sh" validate > /dev/null 2>&1 \
|| fail "source-registry validate should succeed"
}
test_source_registry_matches_urls_and_files_from_shared_table() {
local output
output="$(
bash "$REPO_ROOT/scripts/source-registry.sh" match-url "https://x.com/openai/status/1" 2>&1
)" || fail "source-registry should match X/Twitter URLs"
assert_text_contains "$output" "x_twitter"
output="$(
bash "$REPO_ROOT/scripts/source-registry.sh" match-url "https://mp.weixin.qq.com/s/example" 2>&1
)" || fail "source-registry should match WeChat URLs"
assert_text_contains "$output" "wechat_article"
output="$(
bash "$REPO_ROOT/scripts/source-registry.sh" match-url "https://example.com/post" 2>&1
)" || fail "source-registry should match generic web URLs"
assert_text_contains "$output" "web_article"
output="$(
bash "$REPO_ROOT/scripts/source-registry.sh" match-file "/tmp/example.md" 2>&1
)" || fail "source-registry should match local document files"
assert_text_contains "$output" "local_document"
output="$(
bash "$REPO_ROOT/scripts/source-registry.sh" match-file "/tmp/paper.pdf" 2>&1
)" || fail "source-registry should match PDF files"
assert_text_contains "$output" "local_pdf"
}
test_legacy_wiki_defaults_missing_fields_without_forcing_migration() {
local tmp_dir wiki_root output
tmp_dir="$(mktemp -d)"
trap 'rm -rf "$tmp_dir"' RETURN
wiki_root="$tmp_dir/legacy-wiki"
make_legacy_wiki "$wiki_root"
output="$(
bash "$REPO_ROOT/scripts/wiki-compat.sh" inspect "$wiki_root" 2>&1
)" || fail "legacy wiki inspect should succeed without migration"
assert_text_contains "$output" "schema_version=1.0"
assert_text_contains "$output" "language=zh"
assert_text_contains "$output" "migration_required=no"
assert_text_contains "$output" "missing_optional_raw_dirs=raw/xiaohongshu,raw/zhihu"
assert_text_contains "$output" "purpose_file=missing"
assert_text_contains "$output" "cache_file=missing"
bash "$REPO_ROOT/scripts/wiki-compat.sh" validate "$wiki_root" > /dev/null 2>&1 \
|| fail "legacy wiki validate should accept the old layout"
}
test_legacy_wiki_lazily_creates_new_source_dirs_without_moving_old_materials() {
local tmp_dir wiki_root output
tmp_dir="$(mktemp -d)"
trap 'rm -rf "$tmp_dir"' RETURN
wiki_root="$tmp_dir/legacy-wiki"
make_legacy_wiki "$wiki_root"
printf '旧素材\n' > "$wiki_root/raw/articles/2026-04-01-old-source.md"
bash "$REPO_ROOT/scripts/wiki-compat.sh" ensure-source-dir "$wiki_root" xiaohongshu_post > /dev/null 2>&1 \
|| fail "legacy wiki should lazily create missing source directories"
assert_path_exists "$wiki_root/raw/xiaohongshu"
assert_path_exists "$wiki_root/raw/articles/2026-04-01-old-source.md"
output="$(
bash "$REPO_ROOT/scripts/wiki-compat.sh" inspect "$wiki_root" 2>&1
)" || fail "inspect should still succeed after lazily creating a source directory"
assert_text_contains "$output" "missing_optional_raw_dirs=raw/zhihu"
}
test_new_wiki_compat_reports_purpose_and_cache_present() {
local tmp_dir wiki_root output
tmp_dir="$(mktemp -d)"
trap 'rm -rf "$tmp_dir"' RETURN
wiki_root="$tmp_dir/new-wiki"
bash "$REPO_ROOT/scripts/init-wiki.sh" "$wiki_root" "新知识库" "中文" > /dev/null
output="$(
bash "$REPO_ROOT/scripts/wiki-compat.sh" inspect "$wiki_root" 2>&1
)" || fail "new wiki inspect should succeed"
assert_text_contains "$output" "purpose_file=present"
assert_text_contains "$output" "cache_file=present"
}
test_readme_aligns_source_boundary_to_registry() {
assert_file_contains "$REPO_ROOT/README.md" "## 素材来源"
assert_file_contains "$REPO_ROOT/README.md" "| 核心 |"
assert_file_contains "$REPO_ROOT/README.md" "| 可选 |"
assert_file_contains "$REPO_ROOT/README.md" "| 手动 |"
assert_file_contains "$REPO_ROOT/README.md" "PDF"
assert_file_contains "$REPO_ROOT/README.md" "Markdown"
assert_file_contains "$REPO_ROOT/README.md" "HTML"
assert_file_contains "$REPO_ROOT/README.md" "纯文本粘贴"
assert_file_contains "$REPO_ROOT/README.md" "网页文章"
assert_file_contains "$REPO_ROOT/README.md" "X/Twitter"
assert_file_contains "$REPO_ROOT/README.md" "微信公众号"
assert_file_contains "$REPO_ROOT/README.md" "YouTube"
assert_file_contains "$REPO_ROOT/README.md" "知乎"
assert_file_contains "$REPO_ROOT/README.md" "小红书"
}
test_skill_status_and_ingest_align_to_registry() {
assert_file_contains "$REPO_ROOT/SKILL.md" "scripts/source-registry.sh list"
assert_file_contains "$REPO_ROOT/SKILL.md" "scripts/source-registry.sh get"
assert_file_contains "$REPO_ROOT/SKILL.md" "source_id"
assert_file_contains "$REPO_ROOT/SKILL.md" "recovery_action"
assert_file_contains "$REPO_ROOT/SKILL.md" "install_hint"
assert_file_contains "$REPO_ROOT/SKILL.md" '按来源总表中的 `source_label` 和 `raw_dir`'
assert_file_contains "$REPO_ROOT/SKILL.md" "外挂状态直接使用"
}
test_schema_template_aligns_source_boundary_to_registry() {
assert_file_contains "$REPO_ROOT/templates/schema-template.md" "核心主线"
assert_file_contains "$REPO_ROOT/templates/schema-template.md" "可选外挂"
assert_file_contains "$REPO_ROOT/templates/schema-template.md" "手动入口"
assert_registry_labels_present_in_file "$REPO_ROOT/templates/schema-template.md" "core_builtin"
assert_registry_labels_present_in_file "$REPO_ROOT/templates/schema-template.md" "optional_adapter"
assert_registry_labels_present_in_file "$REPO_ROOT/templates/schema-template.md" "manual_only"
}
test_install_prints_source_boundary_from_registry() {
local tmp_dir output
tmp_dir="$(mktemp -d)"
trap 'rm -rf "$tmp_dir"' RETURN
mkdir -p "$tmp_dir/home/.claude/skills"
output="$(
HOME="$tmp_dir/home" \
bash "$REPO_ROOT/install.sh" --platform claude --dry-run 2>&1
)" || fail "install.sh dry-run should print shared source boundary"
assert_text_contains "$output" "来源边界"
assert_text_contains "$output" "核心主线"
assert_text_contains "$output" "可选外挂"
assert_text_contains "$output" "手动入口"
assert_registry_labels_present_in_text "$output" "core_builtin"
assert_registry_labels_present_in_text "$output" "optional_adapter"
assert_registry_labels_present_in_text "$output" "manual_only"
assert_text_contains "$output" "--with-optional-adapters"
}
test_install_warns_when_managed_source_is_missing() {
assert_file_contains "$REPO_ROOT/install.sh" "安装源文件缺失,跳过"
}
test_validate_step1_no_args_exits_with_usage() {
local output
if output="$(bash "$REPO_ROOT/scripts/validate-step1.sh" 2>&1)"; then
fail "validate-step1.sh should exit non-zero with no args"
fi
assert_text_contains "$output" "usage"
}
test_validate_step1_valid_json_passes() {
local tmp_dir
tmp_dir="$(mktemp -d)"
trap 'rm -rf "$tmp_dir"' RETURN
printf '%s\n' '{"entities":[{"name":"test","type":"concept","confidence":"EXTRACTED"}],"topics":[],"connections":[],"contradictions":[],"new_vs_existing":{}}' \
> "$tmp_dir/step1.json"
bash "$REPO_ROOT/scripts/validate-step1.sh" "$tmp_dir/step1.json" \
|| fail "validate-step1.sh should pass with valid JSON"
}
test_validate_step1_missing_confidence_fails() {
local tmp_dir output
tmp_dir="$(mktemp -d)"
trap 'rm -rf "$tmp_dir"' RETURN
printf '%s\n' '{"entities":[{"name":"test","type":"concept"}],"topics":[],"connections":[],"contradictions":[],"new_vs_existing":{}}' \
> "$tmp_dir/step1.json"
if output="$(bash "$REPO_ROOT/scripts/validate-step1.sh" "$tmp_dir/step1.json" 2>&1)"; then
fail "validate-step1.sh should fail when confidence is missing"
fi
assert_text_contains "$output" "missing required fields"
}
test_validate_step1_invalid_confidence_value_fails() {
local tmp_dir output
tmp_dir="$(mktemp -d)"
trap 'rm -rf "$tmp_dir"' RETURN
printf '%s\n' '{"entities":[{"name":"test","type":"concept","confidence":"HIGH"}],"topics":[],"connections":[],"contradictions":[],"new_vs_existing":{}}' \
> "$tmp_dir/step1.json"
if output="$(bash "$REPO_ROOT/scripts/validate-step1.sh" "$tmp_dir/step1.json" 2>&1)"; then
fail "validate-step1.sh should fail with invalid confidence value"
fi
assert_text_contains "$output" "HIGH"
}
test_validate_step1_entities_not_array_fails() {
local tmp_dir output
tmp_dir="$(mktemp -d)"
trap 'rm -rf "$tmp_dir"' RETURN
printf '%s\n' '{"entities":{},"topics":[],"connections":[],"contradictions":[],"new_vs_existing":{}}' \
> "$tmp_dir/step1.json"
if output="$(bash "$REPO_ROOT/scripts/validate-step1.sh" "$tmp_dir/step1.json" 2>&1)"; then
fail "validate-step1.sh should fail when entities is not an array"
fi
assert_text_contains "$output" "entities"
}
test_validate_step1_empty_entities_array_passes() {
local tmp_dir
tmp_dir="$(mktemp -d)"
trap 'rm -rf "$tmp_dir"' RETURN
printf '%s\n' '{"entities":[],"topics":[],"connections":[],"contradictions":[],"new_vs_existing":{}}' \
> "$tmp_dir/step1.json"
bash "$REPO_ROOT/scripts/validate-step1.sh" "$tmp_dir/step1.json" \
|| fail "validate-step1.sh should pass with empty entities array"
}
test_validate_step1_rejects_non_object_items() {
local tmp_dir output
tmp_dir="$(mktemp -d)"
trap 'rm -rf "$tmp_dir"' RETURN
printf '%s\n' '{"entities":["not-an-object"],"topics":[],"connections":[],"contradictions":[],"new_vs_existing":{}}' \
> "$tmp_dir/entity.json"
if output="$(bash "$REPO_ROOT/scripts/validate-step1.sh" "$tmp_dir/entity.json" 2>&1)"; then
fail "validate-step1.sh should fail when an entity item is not an object"
fi
assert_text_contains "$output" "entity"
printf '%s\n' '{"entities":[],"topics":["not-an-object"],"connections":[],"contradictions":[],"new_vs_existing":{}}' \
> "$tmp_dir/topic.json"
if output="$(bash "$REPO_ROOT/scripts/validate-step1.sh" "$tmp_dir/topic.json" 2>&1)"; then
fail "validate-step1.sh should fail when a topic item is not an object"
fi
assert_text_contains "$output" "topic"
printf '%s\n' '{"entities":[],"topics":[],"connections":["not-an-object"],"contradictions":[],"new_vs_existing":{}}' \
> "$tmp_dir/connection.json"
if output="$(bash "$REPO_ROOT/scripts/validate-step1.sh" "$tmp_dir/connection.json" 2>&1)"; then
fail "validate-step1.sh should fail when a connection item is not an object"
fi
assert_text_contains "$output" "connection"
}
test_lint_runner_accepts_index_alias_entries() {
local tmp_dir output
tmp_dir="$(mktemp -d)"
trap 'rm -rf "$tmp_dir"' RETURN
mkdir -p "$tmp_dir/wiki/entities"
printf '# Index\n\n## 实体页\n- [[Real|真实页面]]\n' > "$tmp_dir/index.md"
printf '# Real\n' > "$tmp_dir/wiki/entities/Real.md"
output="$(bash "$REPO_ROOT/scripts/lint-runner.sh" "$tmp_dir" 2>&1)" \
|| fail "lint-runner.sh should run on alias index fixture"
assert_text_not_contains "$output" "未收录: entities/Real"
}
test_lint_fix_does_not_require_macos_sed_in_place() {
local tmp_dir wiki_root real_sed
tmp_dir="$(mktemp -d)"
trap 'rm -rf "$tmp_dir"' RETURN
wiki_root="$tmp_dir/wiki-root"
real_sed="$(command -v sed)"
mkdir -p "$wiki_root/wiki/entities" "$tmp_dir/bin"
printf '# Index\n\n## 实体页\n' > "$wiki_root/index.md"
printf '# Lonely\n' > "$wiki_root/wiki/entities/Lonely.md"
make_stub "$tmp_dir/bin/sed" "#!/bin/sh
if [ \"\${1:-}\" = \"-i\" ] && [ \"\${2+x}\" = \"x\" ] && [ \"\$2\" = \"\" ]; then
echo 'sed -i empty extension is not portable' >&2
exit 2
fi
exec \"$real_sed\" \"\$@\"
"
PATH="$tmp_dir/bin:$PATH" bash "$REPO_ROOT/scripts/lint-fix.sh" "$wiki_root" > /dev/null \
|| fail "lint-fix.sh should not depend on macOS sed -i syntax"
assert_file_contains "$wiki_root/index.md" "- [[Lonely]]"
}
test_skill_md_ingest_has_confidence_assignment_rules() {
local section
section="$(sed -n '/## 工作流 2ingest/,/## 工作流 3batch-ingest/p' "$REPO_ROOT/SKILL.md")"
assert_text_contains "$section" "EXTRACTED:信息直接出现在原文里"
assert_text_contains "$section" "INFERRED"
assert_text_contains "$section" "AMBIGUOUS"
assert_text_contains "$section" "UNVERIFIED"
assert_text_contains "$section" "validate-step1.sh"
}
test_skill_md_has_crystallize_workflow_and_route() {
local route_section crystallize_section
route_section="$(sed -n '/## 工作流路由/,/## 通用前置检查/p' "$REPO_ROOT/SKILL.md")"
crystallize_section="$(sed -n '/## 工作流 10crystallize/,$p' "$REPO_ROOT/SKILL.md")"
assert_text_contains "$route_section" "crystallize"
assert_text_contains "$crystallize_section" "wiki/synthesis/sessions/"
assert_text_contains "$crystallize_section" "INFERRED"
}
test_init_creates_synthesis_sessions_subdir() {
local tmp_dir
tmp_dir="$(mktemp -d)"
trap 'rm -rf "$tmp_dir"' RETURN
bash "$REPO_ROOT/scripts/init-wiki.sh" "$tmp_dir/wiki" "测试知识库" > /dev/null 2>&1 \
|| fail "init-wiki.sh should succeed"
assert_path_exists "$tmp_dir/wiki/wiki/synthesis/sessions"
}
test_create_source_page_writes_and_updates_cache() {
local tmp_dir wiki_root raw_file content_file output
tmp_dir="$(mktemp -d)"
trap 'rm -rf "$tmp_dir"' RETURN
wiki_root="$tmp_dir/wiki"
bash "$REPO_ROOT/scripts/init-wiki.sh" "$wiki_root" "缓存测试" "中文" > /dev/null
raw_file="$wiki_root/raw/articles/test-article.md"
printf '素材原始内容\n' > "$raw_file"
content_file="$tmp_dir/content.tmp"
printf '# 测试摘要页\n\n这是测试内容。\n' > "$content_file"
output="$(
bash "$REPO_ROOT/scripts/create-source-page.sh" "$raw_file" "wiki/sources/test-article.md" "$content_file" 2>&1
)" || fail "create-source-page.sh should succeed"
assert_text_contains "$output" "SUCCESS"
# 验证页面文件已写入
assert_path_exists "$wiki_root/wiki/sources/test-article.md"
assert_file_contains "$wiki_root/wiki/sources/test-article.md" "这是测试内容"
# 验证缓存已更新(check 返回 HIT)
output="$(
bash "$REPO_ROOT/scripts/cache.sh" check "$raw_file" 2>&1
)"
[ "$output" = "HIT" ] || fail "Expected HIT after create-source-page, got: $output"
}
test_create_source_page_rollback_on_cache_failure() {
local tmp_dir wiki_root raw_file content_file
tmp_dir="$(mktemp -d)"
trap 'rm -rf "$tmp_dir"' RETURN
wiki_root="$tmp_dir/wiki"
mkdir -p "$wiki_root/raw/articles" "$wiki_root/wiki/sources"
# 使用损坏的 cache 文件(不是合法 JSON),让 cache update 失败
printf 'not json\n' > "$wiki_root/.wiki-cache.json"
printf '# Schema\n' > "$wiki_root/.wiki-schema.md"
raw_file="$wiki_root/raw/articles/test-article.md"
printf '素材原始内容\n' > "$raw_file"
content_file="$tmp_dir/content.tmp"
printf '# 测试摘要页\n' > "$content_file"
bash "$REPO_ROOT/scripts/create-source-page.sh" "$raw_file" "wiki/sources/test-article.md" "$content_file" 2>/dev/null \
&& fail "create-source-page.sh should fail when cache update fails"
# 验证页面文件已被回滚删除
[ ! -f "$wiki_root/wiki/sources/test-article.md" ] || fail "Output file should be rolled back on cache failure"
}
test_cache_check_self_heals_with_matching_stem() {
local tmp_dir wiki_root raw_file output
tmp_dir="$(mktemp -d)"
trap 'rm -rf "$tmp_dir"' RETURN
wiki_root="$tmp_dir/wiki"
bash "$REPO_ROOT/scripts/init-wiki.sh" "$wiki_root" "缓存测试" "中文" > /dev/null
raw_file="$wiki_root/raw/articles/rlhf-paper.md"
printf 'RLHF 论文内容\n' > "$raw_file"
# 直接写 source 页面(不通过脚本,模拟 AI 忘了 update)
cat > "$wiki_root/wiki/sources/rlhf-paper.md" <<'SRCEOF'
---
source_path: raw/articles/rlhf-paper.md
---
# RLHF 摘要
SRCEOF
# cache check 应该自愈
output="$(
bash "$REPO_ROOT/scripts/cache.sh" check "$raw_file" 2>&1
)"
[ "$output" = "HIT(repaired)" ] || fail "Expected HIT(repaired) for self-heal, got: $output"
# 再次 check 应返回普通 HIT
output="$(
bash "$REPO_ROOT/scripts/cache.sh" check "$raw_file" 2>&1
)"
[ "$output" = "HIT" ] || fail "Expected HIT after repair, got: $output"
}
test_cache_check_miss_no_source_when_page_deleted() {
local tmp_dir wiki_root raw_file output
tmp_dir="$(mktemp -d)"
trap 'rm -rf "$tmp_dir"' RETURN
wiki_root="$tmp_dir/wiki"
bash "$REPO_ROOT/scripts/init-wiki.sh" "$wiki_root" "缓存测试" "中文" > /dev/null
raw_file="$wiki_root/raw/articles/example.md"
printf '原始内容\n' > "$raw_file"
# 正常建 cache
printf '# 来源页\n' > "$wiki_root/wiki/sources/example.md"
bash "$REPO_ROOT/scripts/cache.sh" update "$raw_file" "wiki/sources/example.md" > /dev/null 2>&1
# 删掉 source 页面
rm "$wiki_root/wiki/sources/example.md"
output="$(
bash "$REPO_ROOT/scripts/cache.sh" check "$raw_file" 2>&1
)"
[ "$output" = "MISS:no_source" ] || fail "Expected MISS:no_source, got: $output"
}
test_cache_check_miss_hash_changed_when_content_differs() {
local tmp_dir wiki_root raw_file output
tmp_dir="$(mktemp -d)"
trap 'rm -rf "$tmp_dir"' RETURN
wiki_root="$tmp_dir/wiki"
bash "$REPO_ROOT/scripts/init-wiki.sh" "$wiki_root" "缓存测试" "中文" > /dev/null
raw_file="$wiki_root/raw/articles/example.md"
printf '原始内容 v1\n' > "$raw_file"
printf '# 来源页\n' > "$wiki_root/wiki/sources/example.md"
bash "$REPO_ROOT/scripts/cache.sh" update "$raw_file" "wiki/sources/example.md" > /dev/null 2>&1
# 修改素材内容
printf '原始内容 v2 已变化\n' > "$raw_file"
output="$(
bash "$REPO_ROOT/scripts/cache.sh" check "$raw_file" 2>&1
)"
[ "$output" = "MISS:hash_changed" ] || fail "Expected MISS:hash_changed, got: $output"
}
test_skill_md_ingest_uses_create_source_page() {
local section
section="$(sed -n '/## 工作流 2ingest/,/## 工作流 3batch-ingest/p' "$REPO_ROOT/SKILL.md")"
assert_text_contains "$section" "create-source-page.sh"
assert_text_contains "$section" "MISS:no_entry"
assert_text_contains "$section" "MISS:hash_changed"
assert_text_contains "$section" "MISS:no_source"
assert_text_contains "$section" "HIT(repaired)"
}
test_skill_md_step12_does_not_call_cache_update() {
local section step12
section="$(sed -n '/## 工作流 2ingest/,/## 工作流 3batch-ingest/p' "$REPO_ROOT/SKILL.md")"
step12="$(printf '%s' "$section" | sed -n '/^12\. \*\*更新 log\.md/,/^13\./p')"
# Step 12 不应包含可执行的 cache.sh update 代码块(只在说明文字里提到)
# 检查没有以 "bash" 开头并含 "cache.sh update" 的行
if printf '%s' "$step12" | grep -E 'bash.*cache\.sh update' > /dev/null; then
fail "Step 12 should not contain executable cache.sh update call"
fi
# 应提到缓存已由 create-source-page.sh 完成
assert_text_contains "$step12" "create-source-page.sh"
}
test_setup_runs_on_bash_3_2
test_install_with_optional_adapters_bootstraps_dependencies
test_install_dry_run_for_claude
test_install_help_does_not_require_python
test_install_dry_run_does_not_require_python
test_install_auto_refuses_ambiguous_platforms
test_upgrade_auto_refuses_ambiguous_installed_platforms
test_upgrade_uses_explicit_target_dir
test_upgrade_fails_when_explicit_target_dir_is_missing
test_upgrade_refreshes_claude_companion_skill
test_install_openclaw_copies_bundle
test_install_hermes_copies_bundle
test_init_fills_language_placeholder
test_phase1_templates_exist
test_init_creates_purpose_and_cache_files
test_cache_script_handles_miss_hit_and_invalidate
test_skill_md_phase2_init_mentions_purpose_and_cache
test_skill_md_phase2_ingest_mentions_two_step_cache_and_confidence
test_skill_md_phase2_batch_ingest_mentions_cache_skip_summary
test_skill_md_phase2_status_mentions_purpose_presence
test_skill_md_phase2_has_delete_workflow_and_route
test_delete_helper_scans_reference_files
test_skill_md_phase3_query_mentions_persistence_and_duplicate_handling
test_hook_session_start_outputs_context_when_wiki_exists
test_hook_session_start_returns_empty_json_without_wiki
test_hook_session_start_without_wiki_does_not_require_python
test_install_registers_and_uninstalls_session_start_hook
test_platform_entries_mention_hook_and_wiki_context
test_root_entries_explain_core_only_optional_and_target_dir
test_skill_md_phase5_lint_mentions_confidence_audit
test_changelog_mentions_wiki_core_upgrades
test_readme_sections
test_claude_upgrade_companion_source_exists
test_uv_tool_install_failure_is_graceful
test_skill_md_routes_wechat_to_new_tool
test_templates_have_no_empty_links
test_batch_ingest_has_step_two
test_english_templates_exist_and_have_placeholders
test_english_templates_have_no_empty_links
test_skill_md_has_shared_preflight_and_language_rules
test_skill_md_uses_external_english_templates_and_no_english_output_blocks
test_setup_wrapper_is_marked_deprecated
test_source_registry_contract_is_frozen
test_source_registry_groups_core_optional_and_manual_sources
test_source_registry_exposes_install_dependency_groups
test_source_registry_validation_passes
test_source_registry_matches_urls_and_files_from_shared_table
test_legacy_wiki_defaults_missing_fields_without_forcing_migration
test_legacy_wiki_lazily_creates_new_source_dirs_without_moving_old_materials
test_new_wiki_compat_reports_purpose_and_cache_present
test_readme_aligns_source_boundary_to_registry
test_skill_status_and_ingest_align_to_registry
test_schema_template_aligns_source_boundary_to_registry
test_install_prints_source_boundary_from_registry
test_install_warns_when_managed_source_is_missing
test_validate_step1_no_args_exits_with_usage
test_validate_step1_valid_json_passes
test_validate_step1_missing_confidence_fails
test_validate_step1_invalid_confidence_value_fails
test_validate_step1_entities_not_array_fails
test_validate_step1_empty_entities_array_passes
test_validate_step1_rejects_non_object_items
test_lint_runner_accepts_index_alias_entries
test_lint_fix_does_not_require_macos_sed_in_place
test_skill_md_ingest_has_confidence_assignment_rules
test_skill_md_has_crystallize_workflow_and_route
test_init_creates_synthesis_sessions_subdir
test_create_source_page_writes_and_updates_cache
test_create_source_page_rollback_on_cache_failure
test_cache_check_self_heals_with_matching_stem
test_cache_check_miss_no_source_when_page_deleted
test_cache_check_miss_hash_changed_when_content_differs
test_skill_md_ingest_uses_create_source_page
test_skill_md_step12_does_not_call_cache_update
# ─── 交互式图谱测试 ────────────────────────────────────────────
GRAPH_DATA_SAMPLE="tests/fixtures/graph-data-sample-wiki"
GRAPH_DATA_EMPTY="tests/fixtures/graph-data-empty-wiki"
GRAPH_HTML_BASIC="tests/fixtures/graph-interactive-basic"
trap - RETURN
source "$REPO_ROOT/tests/lib/graph-html-engine-helpers.sh"
test_graph_data_sample_wiki_matches_expected() {
local tmp_dir
tmp_dir="$(mktemp -d)"
trap 'rm -rf "$tmp_dir"' RETURN
LLM_WIKI_TEST_MODE=1 \
bash "$REPO_ROOT/scripts/build-graph-data.sh" \
"$REPO_ROOT/$GRAPH_DATA_SAMPLE" \
"$tmp_dir/graph-data.json" > /dev/null 2>&1 \
|| fail "build-graph-data.sh should succeed on sample wiki"
jq 'del(.nodes[].source_path)' "$tmp_dir/graph-data.json" > "$tmp_dir/normalized.json"
diff "$tmp_dir/normalized.json" "$REPO_ROOT/tests/expected/graph-data-sample.json" \
|| fail "sample wiki graph-data output differs from expected"
}
test_graph_data_empty_wiki_matches_expected() {
local tmp_dir
tmp_dir="$(mktemp -d)"
trap 'rm -rf "$tmp_dir"' RETURN
LLM_WIKI_TEST_MODE=1 \
bash "$REPO_ROOT/scripts/build-graph-data.sh" \
"$REPO_ROOT/$GRAPH_DATA_EMPTY" \
"$tmp_dir/graph-data.json" > /dev/null 2>&1 \
|| fail "build-graph-data.sh should succeed on empty wiki"
diff "$tmp_dir/graph-data.json" "$REPO_ROOT/tests/expected/graph-data-empty.json" \
|| fail "empty wiki graph-data output differs from expected"
}
test_graph_data_test_mode_is_stable() {
local tmp_dir
tmp_dir="$(mktemp -d)"
trap 'rm -rf "$tmp_dir"' RETURN
LLM_WIKI_TEST_MODE=1 \
bash "$REPO_ROOT/scripts/build-graph-data.sh" \
"$REPO_ROOT/$GRAPH_DATA_SAMPLE" \
"$tmp_dir/run1.json" > /dev/null 2>&1
LLM_WIKI_TEST_MODE=1 \
bash "$REPO_ROOT/scripts/build-graph-data.sh" \
"$REPO_ROOT/$GRAPH_DATA_SAMPLE" \
"$tmp_dir/run2.json" > /dev/null 2>&1
diff "$tmp_dir/run1.json" "$tmp_dir/run2.json" \
|| fail "TEST_MODE output should be identical across runs"
}
test_graph_data_has_three_confidence_types() {
local tmp_dir edges
tmp_dir="$(mktemp -d)"
trap 'rm -rf "$tmp_dir"' RETURN
LLM_WIKI_TEST_MODE=1 \
bash "$REPO_ROOT/scripts/build-graph-data.sh" \
"$REPO_ROOT/$GRAPH_DATA_SAMPLE" \
"$tmp_dir/graph-data.json" > /dev/null 2>&1
edges=$(jq -r '.edges[].type' "$tmp_dir/graph-data.json" | sort -u)
assert_text_contains "$edges" "EXTRACTED"
assert_text_contains "$edges" "INFERRED"
assert_text_contains "$edges" "AMBIGUOUS"
}
test_graph_data_community_clustering() {
local tmp_dir arch_nodes finetune_nodes paper_comm attention_comm
tmp_dir="$(mktemp -d)"
trap 'rm -rf "$tmp_dir"' RETURN
LLM_WIKI_TEST_MODE=1 \
bash "$REPO_ROOT/scripts/build-graph-data.sh" \
"$REPO_ROOT/$GRAPH_DATA_SAMPLE" \
"$tmp_dir/graph-data.json" > /dev/null 2>&1
arch_nodes=$(jq -r '.nodes[] | select(.community == "arch") | .id' "$tmp_dir/graph-data.json" | sort | tr '\n' ' ')
assert_text_contains "$arch_nodes" "Decoder"
assert_text_contains "$arch_nodes" "Encoder"
assert_text_contains "$arch_nodes" "Transformer"
assert_text_contains "$arch_nodes" "arch"
finetune_nodes=$(jq -r '.nodes[] | select(.community == "finetune") | .id' "$tmp_dir/graph-data.json" | sort | tr '\n' ' ')
assert_text_contains "$finetune_nodes" "GPT"
assert_text_contains "$finetune_nodes" "finetune"
attention_comm=$(jq -r '.nodes[] | select(.community == "Attention") | .id' "$tmp_dir/graph-data.json" | sort | tr '\n' ' ')
assert_text_contains "$attention_comm" "Attention"
assert_text_contains "$attention_comm" "paper"
paper_comm=$(jq -r '.nodes[] | select(.id == "paper") | .community' "$tmp_dir/graph-data.json")
[ "$paper_comm" = "Attention" ] || fail "Expected paper community to be Attention, got: $paper_comm"
}
test_graph_data_empty_wiki_has_zero_nodes_and_edges() {
local tmp_dir nodes_count edges_count
tmp_dir="$(mktemp -d)"
trap 'rm -rf "$tmp_dir"' RETURN
LLM_WIKI_TEST_MODE=1 \
bash "$REPO_ROOT/scripts/build-graph-data.sh" \
"$REPO_ROOT/$GRAPH_DATA_EMPTY" \
"$tmp_dir/graph-data.json" > /dev/null 2>&1
nodes_count=$(jq '.meta.total_nodes' "$tmp_dir/graph-data.json")
edges_count=$(jq '.meta.total_edges' "$tmp_dir/graph-data.json")
[ "$nodes_count" = "0" ] || fail "Expected 0 nodes in empty wiki, got: $nodes_count"
[ "$edges_count" = "0" ] || fail "Expected 0 edges in empty wiki, got: $edges_count"
}
test_graph_html_basic_assembly() {
local tmp_dir output_dir
tmp_dir="$(mktemp -d)"
trap 'rm -rf "$tmp_dir"' RETURN
output_dir="$tmp_dir/wiki"
mkdir -p "$output_dir"
cp "$REPO_ROOT/$GRAPH_HTML_BASIC/wiki/graph-data.json" "$output_dir/graph-data.json"
bash "$REPO_ROOT/scripts/build-graph-html.sh" \
"$tmp_dir" > /dev/null 2>&1 \
|| fail "build-graph-html.sh should succeed on basic fixture"
# HTML 文件存在
assert_path_exists "$output_dir/knowledge-graph.html"
# 包含品牌栏占位符替换结果
assert_file_contains "$output_dir/knowledge-graph.html" "HTML测试知识库"
assert_file_contains "$output_dir/knowledge-graph.html" "3"
assert_file_contains "$output_dir/knowledge-graph.html" "2"
# 共享 graph-engine 单文件产物:旧 wash/vendor 外部资产不再复制
assert_single_file_engine_output "$output_dir"
assert_file_contains "$output_dir/knowledge-graph.html" "国风知识库·数字山水图"
assert_file_contains "$output_dir/knowledge-graph.html" "graph-reader"
assert_file_contains "$output_dir/knowledge-graph.html" "node-layer"
assert_file_contains "$output_dir/knowledge-graph.html" "mini-map"
}
test_graph_html_escapes_script_tag_in_content() {
local tmp_dir output_dir html
tmp_dir="$(mktemp -d)"
trap 'rm -rf "$tmp_dir"' RETURN
output_dir="$tmp_dir/wiki"
mkdir -p "$output_dir"
cp "$REPO_ROOT/$GRAPH_HTML_BASIC/wiki/graph-data.json" "$output_dir/graph-data.json"
bash "$REPO_ROOT/scripts/build-graph-html.sh" \
"$tmp_dir" > /dev/null 2>&1
# </script> 必须被转义为 <\/script>
html=$(cat "$output_dir/knowledge-graph.html")
assert_text_not_contains "$html" '</script> 标签'
assert_text_contains "$html" '<\/script> 标签'
}
test_graph_html_escapes_title_text() {
local tmp_dir output_dir html
tmp_dir="$(mktemp -d)"
trap 'rm -rf "$tmp_dir"' RETURN
output_dir="$tmp_dir/wiki"
mkdir -p "$output_dir"
jq '.meta.wiki_title = "<img src=x onerror=alert(1)>"' \
"$REPO_ROOT/$GRAPH_HTML_BASIC/wiki/graph-data.json" \
> "$output_dir/graph-data.json"
bash "$REPO_ROOT/scripts/build-graph-html.sh" \
"$tmp_dir" > /dev/null 2>&1 \
|| fail "build-graph-html.sh should succeed with HTML-like title text"
html=$(cat "$output_dir/knowledge-graph.html")
assert_text_contains "$html" '<title>知识图谱 · &lt;img src=x onerror=alert(1)&gt;</title>'
assert_text_contains "$html" '<h1>&lt;img src=x onerror=alert(1)&gt; 知识舆图</h1>'
assert_text_not_contains "$html" '<h1><img src=x onerror=alert(1)> 知识舆图</h1>'
}
test_graph_html_missing_data_exits_with_error() {
local tmp_dir
tmp_dir="$(mktemp -d)"
trap 'rm -rf "$tmp_dir"' RETURN
mkdir -p "$tmp_dir/wiki"
if bash "$REPO_ROOT/scripts/build-graph-html.sh" "$tmp_dir" > /dev/null 2>&1; then
fail "build-graph-html.sh should fail when graph-data.json is missing"
fi
}
test_graph_html_missing_engine_assets_exits_with_error() {
local tmp_dir
tmp_dir="$(mktemp -d)"
trap 'rm -rf "$tmp_dir"' RETURN
mkdir -p "$tmp_dir/wiki/scripts" "$tmp_dir/wiki"
printf '{}' > "$tmp_dir/wiki/graph-data.json"
# 把脚本复制到临时目录,让它找不到 graph-engine/deps 产物
cp "$REPO_ROOT/scripts/build-graph-html.sh" "$tmp_dir/wiki/scripts/"
chmod +x "$tmp_dir/wiki/scripts/build-graph-html.sh"
if bash "$tmp_dir/wiki/scripts/build-graph-html.sh" "$tmp_dir/wiki" > /dev/null 2>&1; then
fail "build-graph-html.sh should fail when engine assets are missing"
fi
}
test_graph_data_dead_links_are_ignored() {
local tmp_dir output
tmp_dir="$(mktemp -d)"
trap 'rm -rf "$tmp_dir"' RETURN
mkdir -p "$tmp_dir/wiki/entities"
printf '# Alpha\n\n链接到 [[NonExistent]] 实体。\n' > "$tmp_dir/wiki/entities/Alpha.md"
LLM_WIKI_TEST_MODE=1 \
bash "$REPO_ROOT/scripts/build-graph-data.sh" "$tmp_dir" "$tmp_dir/wiki/graph-data.json" > /dev/null 2>&1
# 应该只有 Alpha 一个节点,0 条边(NonExistent 不存在)
local node_count edge_count
node_count=$(jq '.meta.total_nodes' "$tmp_dir/wiki/graph-data.json")
edge_count=$(jq '.meta.total_edges' "$tmp_dir/wiki/graph-data.json")
[ "$node_count" = "1" ] || fail "Expected 1 node, got: $node_count"
[ "$edge_count" = "0" ] || fail "Expected 0 edges (dead link ignored), got: $edge_count"
}
test_graph_data_self_links_are_ignored() {
local tmp_dir
tmp_dir="$(mktemp -d)"
trap 'rm -rf "$tmp_dir"' RETURN
mkdir -p "$tmp_dir/wiki/entities"
printf '# Self\n\n自引用 [[Self]]。\n' > "$tmp_dir/wiki/entities/Self.md"
LLM_WIKI_TEST_MODE=1 \
bash "$REPO_ROOT/scripts/build-graph-data.sh" "$tmp_dir" "$tmp_dir/wiki/graph-data.json" > /dev/null 2>&1
local edge_count
edge_count=$(jq '.meta.total_edges' "$tmp_dir/wiki/graph-data.json")
[ "$edge_count" = "0" ] || fail "Expected 0 edges (self-link ignored), got: $edge_count"
}
test_graph_data_exits_without_jq() {
local tmp_dir output
tmp_dir="$(mktemp -d)"
trap 'rm -rf "$tmp_dir"' RETURN
mkdir -p "$tmp_dir/wiki/entities"
mkdir -p "$tmp_dir/bin"
ln -s /bin/bash "$tmp_dir/bin/bash"
# 用只包含 bash 的隔离 PATH 来测试 jq 依赖缺失,避免宿主机把 jq 装进 /bin
if output="$(PATH="$tmp_dir/bin" "$tmp_dir/bin/bash" "$REPO_ROOT/scripts/build-graph-data.sh" "$tmp_dir" 2>&1)"; then
fail "build-graph-data.sh should fail when jq is not available"
fi
assert_text_contains "$output" "jq"
}
test_graph_data_wiki_dir_missing_exits() {
local tmp_dir
tmp_dir="$(mktemp -d)"
trap 'rm -rf "$tmp_dir"' RETURN
if bash "$REPO_ROOT/scripts/build-graph-data.sh" "$tmp_dir" 2>/dev/null; then
fail "build-graph-data.sh should fail when wiki/ directory is missing"
fi
}
test_graph_data_sample_wiki_matches_expected
test_graph_data_empty_wiki_matches_expected
test_graph_data_test_mode_is_stable
test_graph_data_has_three_confidence_types
test_graph_data_community_clustering
test_graph_data_empty_wiki_has_zero_nodes_and_edges
test_graph_html_basic_assembly
test_graph_html_escapes_script_tag_in_content
test_graph_html_missing_data_exits_with_error
test_graph_html_missing_engine_assets_exits_with_error
bash "$REPO_ROOT/tests/graph-analysis-helper.regression-1.sh" || fail "graph-analysis-helper.regression-1.sh 测试失败"
bash "$REPO_ROOT/tests/graph-build-failures.regression-1.sh" || fail "graph-build-failures.regression-1.sh 测试失败"
bash "$REPO_ROOT/tests/graph-data-confidence-merge.regression-1.sh" || fail "graph-data-confidence-merge.regression-1.sh 测试失败"
bash "$REPO_ROOT/tests/graph-data-source-paths.regression-1.sh" || fail "graph-data-source-paths.regression-1.sh 测试失败"
bash "$REPO_ROOT/tests/graph-html-brand-link.regression-1.sh" || fail "graph-html-brand-link.regression-1.sh 测试失败"
bash "$REPO_ROOT/tests/graph-html-long-label.regression-1.sh" || fail "graph-html-long-label.regression-1.sh 测试失败"
bash "$REPO_ROOT/tests/graph-html-minimap.regression-1.sh" || fail "graph-html-minimap.regression-1.sh 测试失败"
bash "$REPO_ROOT/tests/graph-html-toolbar.regression-1.sh" || fail "graph-html-toolbar.regression-1.sh 测试失败"
bash "$REPO_ROOT/tests/graph-html-drawer-neighbors.regression-1.sh" || fail "graph-html-drawer-neighbors.regression-1.sh 测试失败"
bash "$REPO_ROOT/tests/graph-html-density.regression-1.sh" || fail "graph-html-density.regression-1.sh 测试失败"
bash "$REPO_ROOT/tests/graph-html-insights.regression-1.sh" || fail "graph-html-insights.regression-1.sh 测试失败"
bash "$REPO_ROOT/tests/graph-html-a11y.regression-1.sh" || fail "graph-html-a11y.regression-1.sh 测试失败"
bash "$REPO_ROOT/tests/graph-html-styles.regression-1.sh" || fail "graph-html-styles.regression-1.sh 测试失败"
bash "$REPO_ROOT/tests/graph-html-search.regression-1.sh" || fail "graph-html-search.regression-1.sh 测试失败"
bash "$REPO_ROOT/tests/graph-html-mobile.regression-1.sh" || fail "graph-html-mobile.regression-1.sh 测试失败"
bash "$REPO_ROOT/tests/graph-html-oriental-atlas-contract.regression-1.sh" || fail "graph-html-oriental-atlas-contract.regression-1.sh 测试失败"
bash "$REPO_ROOT/tests/graph-html-oriental-design-contract.regression-1.sh" || fail "graph-html-oriental-design-contract.regression-1.sh 测试失败"
test_graph_data_dead_links_are_ignored
test_graph_data_self_links_are_ignored
test_graph_data_exits_without_jq
test_graph_data_wiki_dir_missing_exits
bash "$REPO_ROOT/tests/adapter-state.sh" || fail "adapter-state.sh 测试失败"
# ─── JS 单测 ──────────────────────────────────────────────────────
node --test "$REPO_ROOT/tests/js/source-signal-eligibility.test.js" || fail "source-signal-eligibility unit tests failed"
node --test "$REPO_ROOT/tests/js/source-signal-coverage.test.js" || fail "source-signal-coverage integration tests failed"
node --test "$REPO_ROOT/tests/js/graph-wash-helpers.test.js" || fail "graph-wash-helpers unit tests failed"
node --test "$REPO_ROOT/tests/js/graph-wash-bootstrap.test.js" || fail "graph-wash bootstrap unit tests failed"
node --test "$REPO_ROOT/tests/js/graph-wash-queue.test.js" || fail "graph-wash queue unit tests failed"
node --test "$REPO_ROOT/tests/js/graph-wash-learning.test.js" || fail "graph-wash learning unit tests failed"
node --test "$REPO_ROOT/tests/js/graph-wash-runtime-state.test.js" || fail "graph-wash runtime state unit tests failed"
# ─── Lint 回归 ────────────────────────────────────────────────────
bash "$REPO_ROOT/tests/lint-output.regression-1.sh" || fail "lint output regression failed"
echo "All regression checks passed."