refactor: 迁移至 bashly CLI 与功能域模块结构

- CLI 定义迁移至 src/bashly.yml(bashly 1.4 生成参数解析),入口收敛为 src/root_command.sh
- 源码按功能域重组:config/storage/integrate/media/pipeline,消除扁平散落
- 新增零依赖测试框架 tests/(123 用例)与 CI 流水线(lint + test + build + 产物一致性)
- build.sh 支持版本单一来源注入与 --check 产物校验;新增 lint.sh(bash -n + shellcheck 零容忍)
- 删除旧单文件 media_organizer.sh 与旧扁平模块
This commit is contained in:
Shuery committed 2026-08-14 22:41:28 +08:00
1 parent d978a13a83
commit 0df07ad0ae
48 files changed
+5813 -6812

No files matched your search

+61
View File
@@ -0,0 +1,61 @@
# ============================================================================
# 特典类别判定表外置测试(src/lib/config/maps.sh + ai_resolve.sh)
# ============================================================================
setup() {
TEST_DIR="$(mktemp -d)"
AUTOMATED=true
load_special_categories "$TEST_DIR/categories.json"
}
teardown() {
rm -rf "$TEST_DIR"
}
# load_special_categories:数组加载(顺序 = 优先级)
test_load_special_categories_basic() {
cat >"$TEST_DIR/categories.json" <<'EOF'
[
{"match": "wow", "tag": "新类别"},
{"match": "menu", "tag": "自定义Menu"}
]
EOF
load_special_categories "$TEST_DIR/categories.json"
assert_eq "2" "${#SPECIAL_CATEGORIES[@]}"
# 用户表优先于内置默认表
assert_eq "自定义Menu" "$(special_category_tag "Menu01")"
assert_eq "新类别" "$(special_category_tag "wow01")"
}
# special_category_tag:用户表未命中 → 回退内置默认表
test_special_category_tag_fallback_builtin() {
assert_eq "NCED" "$(special_category_tag "NCED02")"
assert_eq "CM" "$(special_category_tag "TVSP")"
assert_eq "Special" "$(special_category_tag "完全未知词")"
}
# special_category_tag:空表(文件缺失/自动化)→ 内置默认表全量工作
test_special_category_tag_empty_table() {
assert_eq "Menu" "$(special_category_tag "Menu01")"
assert_eq "PV" "$(special_category_tag "Announcement")"
assert_eq "ED" "$(special_category_tag "Lyric")"
assert_eq "Music Video" "$(special_category_tag "MV")"
assert_eq "Picture Drama" "$(special_category_tag "Drama")"
assert_eq "IV" "$(special_category_tag "IV01")"
}
# load_special_categories:非法格式 → 空表不阻断
test_load_special_categories_invalid() {
echo '{"a": 1}' >"$TEST_DIR/categories.json"
load_special_categories "$TEST_DIR/categories.json"
assert_eq "0" "${#SPECIAL_CATEGORIES[@]}"
}
# init_special_categories:自动化 + 文件缺失 → 不创建、空表(内置兜底可用)
test_init_special_categories_automated_missing() {
MATCH_FILE="$TEST_DIR/missing/special_categories.json"
init_special_categories "$MATCH_FILE"
assert_file_absent "$MATCH_FILE"
assert_eq "0" "${#SPECIAL_CATEGORIES[@]}"
assert_eq "Special" "$(special_category_tag "未知")"
}
+108
View File
@@ -0,0 +1,108 @@
# ============================================================================
# 匹配规则测试(src/lib/rules.sh):match_rules.json 加载与查询
# ============================================================================
setup() {
TEST_DIR="$(mktemp -d)"
AUTOMATED=true # 跳过交互创建
load_match_rules "$TEST_DIR/match_rules.json"
}
teardown() {
rm -rf "$TEST_DIR"
}
# load_match_rules:搜索别名(字符串与对象形式)+ ID 映射
test_load_match_rules_basic() {
cat >"$TEST_DIR/match_rules.json" <<'EOF'
{
"search_aliases": {
"俺妹": "Ore no Imouto ga Konna ni Kawaii Wake ga Nai",
"路人女主剧场版": {"term": "Saekano the Movie", "year": "2019"}
},
"id_maps": {
"movie": {"刀剑神域": 20982},
"tv": {"魔法禁书目录": 4654}
}
}
EOF
load_match_rules "$TEST_DIR/match_rules.json"
# 别名:字符串形式(无 year)与对象形式(带 year)
assert_eq "Ore no Imouto ga Konna ni Kawaii Wake ga Nai " \
"$(match_rule_lookup_alias "俺妹")"
assert_eq "Saekano the Movie 2019" \
"$(match_rule_lookup_alias "路人女主剧场版")"
# ID 映射:movie/tv 分表,键归一化后命中
assert_eq "20982" "${MATCH_RULES_ID_MOVIE[刀剑神域]:-}"
assert_eq "4654" "${MATCH_RULES_ID_TV[魔法禁书目录]:-}"
# 未命中
assert_empty "$(match_rule_lookup_alias "不存在的标题")"
assert_empty "${MATCH_RULES_ID_MOVIE[不存在的标题]:-}"
assert_empty "${MATCH_RULES_ID_TV[不存在的标题]:-}"
}
# load_match_rules:键归一化(大小写/空白)后命中
test_load_match_rules_key_normalized() {
cat >"$TEST_DIR/match_rules.json" <<'EOF'
{
"search_aliases": {
"Sword Art Online": "ソードアート・オンライン"
},
"id_maps": {
"movie": {"A B": 1}
}
}
EOF
load_match_rules "$TEST_DIR/match_rules.json"
assert_eq "ソードアート・オンライン " "$(match_rule_lookup_alias "sword art online")"
assert_eq "1" "${MATCH_RULES_ID_MOVIE[a b]:-}"
}
# load_match_rules:文件缺失 → 空规则(不报错)
test_load_match_rules_missing_file() {
assert_eq "0" "${#MATCH_RULES_ALIAS_TERM[@]}"
assert_empty "$(match_rule_lookup_alias "任意标题")"
}
# load_match_rules:非 JSON 对象 → 空规则 + 不阻断
test_load_match_rules_invalid_json() {
echo 'not json' >"$TEST_DIR/match_rules.json"
load_match_rules "$TEST_DIR/match_rules.json"
assert_eq "0" "${#MATCH_RULES_ALIAS_TERM[@]}"
assert_eq "0" "${#MATCH_RULES_ID_MOVIE[@]}"
}
# load_match_rules:空别名 term 的条目跳过(不产生空键)
test_load_match_rules_empty_term_skipped() {
cat >"$TEST_DIR/match_rules.json" <<'EOF'
{
"search_aliases": {
"空词": ""
},
"id_maps": {
"tv": {"空ID": ""}
}
}
EOF
load_match_rules "$TEST_DIR/match_rules.json"
assert_eq "0" "${#MATCH_RULES_ALIAS_TERM[@]}"
assert_eq "0" "${#MATCH_RULES_ID_TV[@]}"
}
# init_match_rules:自动化模式 + 文件缺失 → 不创建、空规则
test_init_match_rules_automated_missing() {
MATCH_RULES_FILE="$TEST_DIR/nonexistent/match_rules.json"
init_match_rules "$MATCH_RULES_FILE"
assert_file_absent "$MATCH_RULES_FILE"
assert_eq "0" "${#MATCH_RULES_ALIAS_TERM[@]}"
}
# init_match_rules:文件存在 → 正常加载
test_init_match_rules_existing() {
cat >"$TEST_DIR/rules.json" <<'EOF'
{"search_aliases": {"别名": "Term"}, "id_maps": {"movie": {"影": 9}}}
EOF
init_match_rules "$TEST_DIR/rules.json"
assert_eq "Term " "$(match_rule_lookup_alias "别名")"
assert_eq "9" "${MATCH_RULES_ID_MOVIE[影]:-}"
}
+114
View File
@@ -0,0 +1,114 @@
# ============================================================================
# 配置文件类目分区测试(src/lib/config/maps.sh)
# ============================================================================
setup() {
TEST_DIR="$(mktemp -d)"
AUTOMATED=true
# 干净状态
SPECIAL_WORDS_CAT=()
SKIP_DIRS_CAT=()
SPECIAL_MAP_CAT=()
RAW_SPECIAL_MAP_CAT=()
SEASON_OFFSET_CAT=()
SKIP_DIRS=()
SKIP_DIRS_LOWER=()
SPECIAL_WORDS=()
}
teardown() {
rm -rf "$TEST_DIR"
}
# is_category_partitioned:分区形态判定
test_is_category_partitioned() {
echo '{"tv": ["menu"], "musicvideo": ["mv"], "default": ["x"]}' >"$TEST_DIR/p.json"
assert_success is_category_partitioned "$TEST_DIR/p.json"
echo '{"menu": ["Menu"]}' >"$TEST_DIR/g.json"
assert_failure is_category_partitioned "$TEST_DIR/g.json"
echo '{"tv": ["menu"], "menu": ["x"]}' >"$TEST_DIR/mixed.json"
assert_failure is_category_partitioned "$TEST_DIR/mixed.json"
}
# init_special_words:分区形态 → 词按类目查询(musicvideo 不回退 tv 词表)
test_partitioned_special_words() {
cat >"$TEST_DIR/kw.json" <<'EOF'
{"tv": ["menu", "cm"], "musicvideo": ["mv", "live"], "default": ["通用"]}
EOF
init_special_words "$TEST_DIR/kw.json"
# tv 分区词
assert_eq "menu" "$(special_word_hit tv "menu01")"
# musicvideo 分区词
assert_eq "mv" "$(special_word_hit musicvideo "mv")"
# tv 分区词不落入 musicvideo(子串 "mv" 在 musicvideo 分区,tv 词不回退)
assert_empty "$(special_word_hit musicvideo "cm")"
# default 分区词两则命中
assert_eq "通用" "$(special_word_hit tv "通用词")"
assert_eq "通用" "$(special_word_hit musicvideo "通用词")"
# 精确匹配:目录信号语义("tmp.xxx" 不命中任何词)
assert_failure special_word_exact musicvideo "tmp.xxx"
assert_success special_word_exact musicvideo "mv"
assert_success special_word_exact musicvideo "live"
}
# init_skip_dirs:分区形态 = 完整语义(不叠加内置默认)
test_partitioned_skip_dirs() {
cat >"$TEST_DIR/skips.json" <<'EOF'
{"video": ["sps", "specials"], "music": ["scans", "fonts"], "default": ["特典"]}
EOF
init_skip_dirs "$TEST_DIR/skips.json"
assert_success skip_dir_in video "sps"
# music 分区未含 sps → 不回退内置默认(分区完整语义)
assert_failure skip_dir_in music "sps"
assert_success skip_dir_in music "scans"
# default 分区回退
assert_success skip_dir_in video "特典"
assert_success skip_dir_in music "特典"
# 分区外词不命中(内置默认不叠加)
assert_failure skip_dir_in video "cds"
}
# 全局形态(旧版数组文件)保持行为:文件内容优先,空数组回退内置默认
test_legacy_skip_dirs_global() {
echo '["myspecial"]' >"$TEST_DIR/skips_legacy.json"
init_skip_dirs "$TEST_DIR/skips_legacy.json"
assert_success skip_dir_in video "myspecial"
# 非空数组文件 = 文件内容优先(不叠加内置默认)
assert_failure skip_dir_in video "sps"
}
# 全局形态空数组 → 内置默认兜底
test_legacy_skip_dirs_empty_falls_back() {
echo '[]' >"$TEST_DIR/skips_empty.json"
init_skip_dirs "$TEST_DIR/skips_empty.json"
assert_success skip_dir_in video "sps"
}
# load_special_map:分区形态(tv 分区 + default 分区回退 + 引用展开)
test_partitioned_special_map() {
cat >"$TEST_DIR/maps.json" <<'EOF'
{"tv": {"menu": ["Menu", "菜单"], "menu01": "menu"}, "default": {"cm": ["CM"]}}
EOF
load_special_map "$TEST_DIR/maps.json"
assert_eq '["Menu","菜单"]' "$(special_map_value tv "menu")"
# 分区内引用展开
assert_eq '["Menu","菜单"]' "$(special_map_value tv "menu01")"
# default 分区回退
assert_eq '["CM"]' "$(special_map_value tv "cm")"
# 无映射键 → 空
assert_empty "$(special_map_value tv "不存在")"
}
# init_season_offset:分区形态(tv 分区 + default 分区回退)
test_partitioned_season_offset() {
cat >"$TEST_DIR/offsets.json" <<'EOF'
{"tv": {"demo show": 5}, "default": {"id:123": 3}}
EOF
SEASON_OFFSET_FILE="$TEST_DIR/offsets.json" init_season_offset "$TEST_DIR/offsets.json"
assert_eq "5" "$(season_offset_get tv "demo show")"
assert_eq "3" "$(season_offset_get tv "id:123")"
# 内置默认仍可用(全局表兜底)
assert_eq "23" "$(season_offset_get tv "jujutsu kaisen")"
# 未命中 → 空
assert_empty "$(season_offset_get tv "no such show")"
}
+79
View File
@@ -0,0 +1,79 @@
# ============================================================================
# 用户自定义规则测试(src/lib/rules.sh):命名模板渲染 + 匹配规则键
# ============================================================================
# render_naming_template:普通占位符 + 补零
test_render_plain_and_padding() {
assert_eq "S01E02" "$(render_naming_template 'S{season:02}E{episode:02}' season=1 episode=2)"
assert_eq "Season 12" "$(render_naming_template 'Season {season}' season=12)"
}
# render_naming_template:条件段(year 非空渲染,空则不渲染)
test_render_conditional_year() {
assert_eq "Movie (2020)" "$(render_naming_template '{title}{?year: ({year})}' title=Movie year=2020)"
assert_eq "Movie" "$(render_naming_template '{title}{?year: ({year})}' title=Movie year=)"
}
# render_naming_template:条件段正文含嵌套占位符
test_render_conditional_nested_placeholder() {
assert_eq "Show [2020]" "$(render_naming_template '{title}{?year: [{year}]}' title=Show year=2020)"
}
# render_naming_template:默认剧集模板全链路(含空集名条件段)
test_render_episode_template_full() {
assert_eq "S01E02 - Name" \
"$(render_naming_template 'S{season:02}E{episode:02}{range}{?episode_name: - {episode_name}}' season=01 episode=02 range= 'episode_name=Name')"
assert_eq "S01E02" \
"$(render_naming_template 'S{season:02}E{episode:02}{range}{?episode_name: - {episode_name}}' season=01 episode=02 range= 'episode_name=')"
assert_eq "S01E02-E03 - A - B" \
"$(render_naming_template 'S{season:02}E{episode:02}{range}{?episode_name: - {episode_name}}' season=01 episode=02 range=-E03 'episode_name=A - B')"
}
# render_naming_template:值含 & 不被替换语义破坏(${var//pat/repl} 会把 & 当匹配整体)
test_render_ampersand_safe() {
assert_eq "A & B" "$(render_naming_template '{episode_name}' 'episode_name=A & B')"
assert_eq "Artist & Band - Album" \
"$(render_naming_template '{artist} - {album}' 'artist=Artist & Band' album=Album)"
}
# render_naming_template:值内花括号被剔除(防破坏模板解析)
test_render_braces_in_value_stripped() {
assert_eq "AB" "$(render_naming_template '{title}' 'title=A{B')"
}
# render_naming_template:未知占位符渲染为空
test_render_unknown_placeholder_empty() {
assert_eq "X " "$(render_naming_template '{title} {foo}' title=X)"
}
# render_naming_template:补零对非数字值原样保留
test_render_padding_non_numeric() {
assert_eq "abc" "$(render_naming_template '{n:03}' n=abc)"
}
# render_naming_template:无配对右花括号按字面保留
test_render_unclosed_brace_literal() {
assert_eq "X {tail" "$(render_naming_template 'X {tail' title=X)"
}
# render_naming:配置键未设置时用内置默认(测试环境不经过 load_config)
test_render_naming_default_fallback() {
assert_eq "Season 03" "$(render_naming NAMING_SEASON season=3)"
assert_eq "Movie (2020)" "$(render_naming NAMING_MOVIE title=Movie year=2020)"
assert_eq "S00Menu - Menu01" "$(render_naming NAMING_SPECIAL tag=Menu fragment=Menu01)"
assert_eq "Artist/Album" "$(render_naming NAMING_MUSIC artist=Artist album=Album)"
}
# template_token_names:提取占位符名(含条件段正文内的;重复名由调用方去重)
test_template_token_names() {
assert_eq "$(printf 'title\nyear' | sort)" "$(template_token_names '{title}{?year: ({year})}' | sort -u)"
assert_eq "$(printf 'season\nepisode\nrange\nepisode_name' | sort)" \
"$(template_token_names 'S{season:02}E{episode:02}{range}{?episode_name: - {episode_name}}' | sort -u)"
}
# rule_key:小写 + 空白折叠 + 去首尾空白
test_rule_key_normalization() {
assert_eq "sword art online" "$(rule_key ' Sword Art ONLINE ')"
assert_eq "sword art online" "$(rule_key 'Sword Art Online')"
assert_eq "俺妹" "$(rule_key '俺妹')"
}
+83
View File
@@ -0,0 +1,83 @@
# ============================================================================
# AI 批处理模块测试(src/lib/ai.sh)
# ============================================================================
setup() {
AI_BATCH_SIZE=50
AI_BATCH_KEYS_SEARCH=()
AI_BATCH_KEYS_SPECIAL=()
AI_BATCH_KEYS_ARTIST=()
AI_BATCH_KEYS_MATCH=()
PENDING_AI_SEARCH=()
PENDING_AI_SPECIAL=()
PENDING_AI_ARTIST=()
PENDING_AI_MATCH=()
}
# ai_extract_json:直接 JSON
test_ai_extract_direct_json() {
assert_eq '{"a": 1}' "$(ai_extract_json '{"a": 1}')"
}
# ai_extract_json:```json 围栏
test_ai_extract_fenced_json() {
local input=$'```json\n{"a": 1}\n```'
assert_eq '{"a": 1}' "$(ai_extract_json "$input")"
}
# ai_extract_json:异行 <thinking> 标签
test_ai_extract_thinking_block() {
local input=$'<thinking>分析中</thinking>\n{"a": 1}'
assert_eq '{"a": 1}' "$(ai_extract_json "$input")"
}
# ai_extract_json:同行 <thinking> 标签(awk 行级处理无法吞掉,需 sed 预处理)
test_ai_extract_thinking_inline() {
assert_eq '{"a": 1}' "$(ai_extract_json '<thinking>分析中</thinking>{"a": 1}')"
}
# ai_extract_json:「思考:」前缀
test_ai_extract_thinking_prefix() {
assert_eq '{"a": 1}' "$(ai_extract_json '思考:这是答案。{"a": 1}')"
}
# ai_extract_json:非 JSON 返回空
test_ai_extract_invalid_returns_empty() {
assert_eq "" "$(ai_extract_json 'not json at all')"
assert_eq "" "$(ai_extract_json '')"
}
# build_ai_input_json:三类条目构造正确的 JSON 结构
test_ai_input_json_structure() {
PENDING_AI_SEARCH["/m/电影 (2020).mkv"]="movie dir"
PENDING_AI_SPECIAL["123|Menu01"]="Show|01:Menu; 02:CM"
PENDING_AI_ARTIST["/m/song.flac"]="A; B|Album|" # 值格式:artists|album|rel_dir
local json rc=0
build_ai_input_json json
echo "$json" | jq -e '
(.search_entries | length) == 1 and
(.special_entries | length) == 1 and
(.artist_entries | length) == 1 and
(.match_entries | length) == 0
' >/dev/null || rc=$?
assert_eq '0' "$rc" "JSON 结构校验"
assert_eq "/m/电影 (2020).mkv" "$(echo "$json" | jq -r '.search_entries[0].file')"
assert_eq "123" "$(echo "$json" | jq -r '.special_entries[0].show_id')"
assert_eq "A; B" "$(echo "$json" | jq -r '.artist_entries[0].artists')"
# 副作用:keys 数组在调用方可见(修复子 shell 丢失 bug 后)
assert_eq '1' "${#AI_BATCH_KEYS_SEARCH[@]}"
assert_eq '1' "${#AI_BATCH_KEYS_SPECIAL[@]}"
assert_eq '1' "${#AI_BATCH_KEYS_ARTIST[@]}"
}
# build_ai_input_json:批大小上限
test_ai_input_json_batch_limit() {
AI_BATCH_SIZE=2
PENDING_AI_SEARCH["/a1.mkv"]="movie "
PENDING_AI_SEARCH["/a2.mkv"]="movie "
PENDING_AI_SEARCH["/a3.mkv"]="movie "
local json
build_ai_input_json json
assert_eq '2' "$(echo "$json" | jq '.search_entries | length')"
assert_eq '2' "${#AI_BATCH_KEYS_SEARCH[@]}"
}
+53
View File
@@ -0,0 +1,53 @@
# ============================================================================
# 特典命名模块测试(src/lib/ai_resolve.sh)
# ============================================================================
setup() {
FOLDER_MOVIES="/media/Movies"
FOLDER_SHOWS="/media/Shows"
FOLDER_UNKNOWN="/media/Unknown"
}
# special_category_tag:特典类别识别
test_special_category_tag_mapping() {
assert_eq "Menu" "$(special_category_tag "Menu01")"
assert_eq "NCED" "$(special_category_tag "NCED02")"
assert_eq "NCOP" "$(special_category_tag "NCOP")"
assert_eq "CM" "$(special_category_tag "CM01")"
assert_eq "CM" "$(special_category_tag "TVSP")"
assert_eq "PV" "$(special_category_tag "Announcement")"
assert_eq "ED" "$(special_category_tag "Lyric")"
assert_eq "Picture Drama" "$(special_category_tag "Drama")"
assert_eq "Music Video" "$(special_category_tag "MV")"
assert_eq "Special" "$(special_category_tag "OVA")"
assert_eq "Special" "$(special_category_tag "未知词")"
}
# special_s00_dest:S00 目标路径(show_dir 含年份段,由 NAMING_SHOW 渲染)
test_special_s00_dest_path() {
# 参数:show_dir + fragment + ext
assert_eq "/media/Shows/Show (2020)/Season 00|S00Menu - Menu01.mkv" \
"$(special_s00_dest "Show (2020)" "Menu01" "mkv")"
assert_eq "/media/Shows/Show/Season 00|S00Special - SP.mkv" \
"$(special_s00_dest "Show" "SP" "mkv")"
}
# fallback_naming:电影回退命名
test_fallback_naming_movie() {
assert_eq "/media/Movies/Movie (2020)|Movie (2020).mkv" \
"$(fallback_naming "movie" "Movie" "2020" "" "" "" "" "mkv")"
}
# fallback_naming:剧集回退命名
test_fallback_naming_tv() {
assert_eq "/media/Shows/Show/Season 01|S01E02.mkv" \
"$(fallback_naming "tv" "Show" "" "1" "2" "" "" "mkv")"
assert_eq "/media/Shows/Show (2021)/Season 02|S02E03-E04.mkv" \
"$(fallback_naming "tv" "Show" "2021" "2" "3" "4" "" "mkv")"
}
# fallback_naming:特典回退命名(走 special_s00_dest)
test_fallback_naming_special() {
assert_eq "/media/Shows/Show/Season 00|S00CM - CM01.mkv" \
"$(fallback_naming "tv" "Show" "" "0" "1" "" "CM01" "mkv")"
}
+103
View File
@@ -0,0 +1,103 @@
# ============================================================================
# 媒体文件名解析模块测试(src/lib/filename.sh)
# ============================================================================
setup() {
TEST_DIR="$(mktemp -d)"
SOURCE_DIR="$TEST_DIR"
SKIP_DIRS_LOWER=(sp sps special specials cd cds bonus bonuses extra extras previews "ps3 special bd" scans fonts 特典 特番 花絮 视频 剧集 动画 合集 电影 音乐 movies series shows anime collections)
SPECIAL_WORDS=("menu" "ncop" "nced" "mini anime" "pv" "cm" "sp" "teaser" "program" "promo" "trailer" "special" "music video" "mv" "opening" "ending" "特典" "特番" "花絮")
VIDEO_EXTS=(mp4 mkv avi mov wmv flv m4v ts m2ts webm)
MAIN_COUNT_CACHE=()
}
teardown() {
rm -rf "$TEST_DIR"
}
# parse_media_filename:标准 S##E## 格式
test_parse_tv_sxxexx() {
assert_eq "tv|Show Name||01|02||" "$(parse_media_filename "$TEST_DIR/Show Name S01E02.mkv")"
}
# parse_media_filename:S##E## + 年份
test_parse_tv_with_year() {
assert_eq "tv|Show|2020|01|01||" "$(parse_media_filename "$TEST_DIR/Show (2020) S01E01.mkv")"
}
# parse_media_filename:多集区间 S01E01-E02
test_parse_tv_episode_range() {
assert_eq "tv|Show||01|01|02|" "$(parse_media_filename "$TEST_DIR/Show S01E01-E02.mkv")"
}
# parse_media_filename:#x## 格式
test_parse_tv_hash_format() {
assert_eq "tv|Show||1|05||" "$(parse_media_filename "$TEST_DIR/Show 1x05.mkv")"
}
# parse_media_filename:Season 2 格式(无集号 → 0)
test_parse_tv_season_format() {
assert_eq "tv|Show||2|0|" "$(parse_media_filename "$TEST_DIR/Show Season 2.mkv")"
}
# parse_media_filename:2nd Season + [NN]
test_parse_tv_ordinal_season() {
assert_eq "tv|Show||2|01|" "$(parse_media_filename "$TEST_DIR/Show 2nd Season [01].mkv")"
}
# parse_media_filename:方括号集号 [NN]
test_parse_tv_bracket_episode() {
assert_eq "tv|Show||1|07||" "$(parse_media_filename "$TEST_DIR/Show [07].mkv")"
}
# parse_media_filename:小数集号 → 特典路径(season=0)
test_parse_tv_decimal_episode_as_special() {
assert_eq "tv|Show||0|||14.5" "$(parse_media_filename "$TEST_DIR/Show [14.5].mkv")"
}
# parse_media_filename:第 0 话 → 特典路径
test_parse_tv_zero_episode_as_special() {
assert_eq "tv|Show||0|||0" "$(parse_media_filename "$TEST_DIR/Show [0].mkv")"
}
# parse_media_filename:电影(年份)
test_parse_movie_with_year() {
assert_eq "movie|Movie|2020||||" "$(parse_media_filename "$TEST_DIR/Movie (2020).mkv")"
}
# parse_media_filename:压制组风格电影
test_parse_movie_group_style() {
assert_eq "movie|Movie|2021||||" "$(parse_media_filename "$TEST_DIR/[Group] Movie (2021) [1080p].mkv")"
}
# detect_special:父目录为特典目录(SPs)
test_detect_special_parent_dir() {
mkdir -p "$TEST_DIR/Show/SPs"
touch "$TEST_DIR/Show/SPs/CM01.mkv"
assert_eq "tv|Show||0|01||CM01" "$(parse_media_filename "$TEST_DIR/Show/SPs/CM01.mkv")"
}
# detect_special:文件名含特典标记 [CM01]
test_detect_special_bracket_mark() {
# Show 目录需 ≥2 个正片(count==1 会被判为电影特典跳过)
mkdir -p "$TEST_DIR/Show"
touch "$TEST_DIR/Show/[CM01].mkv" "$TEST_DIR/Show/EP01.mkv"
assert_eq "tv|Show||0|01||CM01" "$(parse_media_filename "$TEST_DIR/Show/[CM01].mkv")"
}
# find_show_dir_from_path:跳过特典目录找到剧名目录
test_find_show_dir_skips_special_dirs() {
assert_eq "Show" "$(find_show_dir_from_path "$TEST_DIR/Show/SPs/CM01.mkv")"
}
# find_show_dir_from_path:跳过 [数字 CD 子目录
test_find_show_dir_skips_cd_dir() {
assert_eq "Show" "$(find_show_dir_from_path "$TEST_DIR/Show/[200226] Song/01.flac")"
}
# count_main_videos:统计正片、跳过特典子目录
test_count_main_videos_counts_and_skips() {
mkdir -p "$TEST_DIR/Show" "$TEST_DIR/Show/SPs"
touch "$TEST_DIR/Show/EP01.mkv" "$TEST_DIR/Show/EP02.mkv" "$TEST_DIR/Show/SPs/CM01.mkv"
assert_eq "2" "$(count_main_videos "$TEST_DIR/Show")"
}
+207
View File
@@ -0,0 +1,207 @@
# ============================================================================
# 识别搜索链测试(src/lib/media/identify.sh)
#
# 覆盖 R1 搜索链抽象后的行为保持:统一尝试器(tv_search_once/movie_search_once)
# 的命中/无结果/请求失败返回码、两行协议回传、识别函数的层级短路语义
# (主搜索失败即 return 2,后续层失败忽略)、目录/MAL/别名/后缀剥离兜底。
#
# mock 策略:按 "endpoint|语言" 查 MOCK_RESPONSES 表;无表项 = 空哨兵(无结果);
# MOCK_FAIL_ENDPOINTS 匹配 → 请求失败(rc=2)。tmdb_api_lang 经动态作用域
# 透传 TMDB_LANG,mock 用它区分 zh/en 响应。
# ============================================================================
# mock 响应表(文件级全局:declare 在函数内是函数局部,mock 函数访问不到)
declare -A MOCK_RESPONSES=()
declare -A MOCK_FAIL_ENDPOINTS=()
setup() {
TEST_DIR="$(mktemp -d)"
DRY_RUN=false
TMDB_DELAY=0
TMDB_LANG="zh-CN"
SOURCE_DIR="$TEST_DIR/src"
FOLDER_SHOWS="节目"
FOLDER_MOVIES="电影"
mkdir -p "$SOURCE_DIR"
MOCK_RESPONSES=()
MOCK_FAIL_ENDPOINTS=()
}
teardown() {
rm -rf "$TEST_DIR"
}
# mock:按 "endpoint|lang" 查表;无表项 → 无结果(rc=1);失败表命中 → rc=2
tmdb_api() {
local endpoint="$1"
shift
local key="${endpoint}|${TMDB_LANG:-}"
if [[ -n "${MOCK_FAIL_ENDPOINTS[$endpoint]:-}" ]]; then
return 2
fi
local resp="${MOCK_RESPONSES[$key]:-}"
[[ -z "$resp" ]] && return 1
echo "$resp"
}
# mock:用户规则别名查找(真实函数读规则文件,测试桩直接返回)
match_rule_lookup_alias() {
echo ""
}
# --- 统一尝试器 ---
# tv_search_once:zh 命中 → 输出 id,返回 0
test_tv_search_once_hit() {
MOCK_RESPONSES["/search/tv|zh-CN"]='{"results":[{"id":100,"name":"Show A"}]}'
local id="" rc=0
id=$(tv_search_once "Show A" "1" "" "") || rc=$?
assert_eq "0" "$rc" "tv_search_once 命中返回码"
assert_eq "100" "$id" "tv_search_once 命中 id"
}
# tv_search_once:无结果 → 返回 1,无输出
test_tv_search_once_miss() {
local id="" rc=0
id=$(tv_search_once "No Such Show" "1" "" "") || rc=$?
assert_eq "1" "$rc" "tv_search_once 无结果返回码"
assert_empty "$id" "tv_search_once 无结果输出"
}
# tv_search_once:请求失败 → 返回 2(不重试语义由调用方处理)
test_tv_search_once_request_failed() {
MOCK_FAIL_ENDPOINTS["/search/tv"]=1
local rc=0
tv_search_once "Show A" "1" "" "" >/dev/null || rc=$?
assert_eq "2" "$rc" "tv_search_once 请求失败返回码"
}
# tv_search_once:zh 空 → en-US 命中(语言分支走 tmdb_api_lang)
test_tv_search_once_en_fallback() {
MOCK_RESPONSES["/search/tv|en-US"]='{"results":[{"id":200,"name":"Show A"}]}'
local id="" rc=0
id=$(tv_search_once "Show A" "1" "" "en-US") || rc=$?
assert_eq "0" "$rc" "en 命中返回码"
assert_eq "200" "$id" "en 命中 id"
}
# movie_search_once:两行协议(id + 单行响应 JSON)
test_movie_search_once_two_line_protocol() {
MOCK_RESPONSES["/search/movie|zh-CN"]='{"results":[{"id":50,"title":"Movie X","release_date":"2020-01-01"}]}'
local out="" rc=0
out=$(movie_search_once "Movie X" "2020" "") || rc=$?
assert_eq "0" "$rc" "movie_search_once 命中返回码"
local id resp
id=$(printf '%s\n' "$out" | sed -n '1p')
resp=$(printf '%s\n' "$out" | sed -n '2p')
assert_eq "50" "$id" "两行协议 id 行"
assert_contains "$resp" '"title":"Movie X"' "两行协议响应行"
}
# --- 目录兜底 ---
# tv_search_by_dir:源根直属散放不兜底(无输出)
test_tv_search_by_dir_source_root_skipped() {
local id
id=$(tv_search_by_dir "$SOURCE_DIR/Show.mkv" "Show" "1" "")
assert_empty "$id" "源根散放不兜底"
}
# tv_search_by_dir:目录名命中(zh → en)
test_tv_search_by_dir_hit() {
mkdir -p "$TEST_DIR/src/[Group] Show X"
local f="$TEST_DIR/src/[Group] Show X/01.mkv"
MOCK_RESPONSES["/search/tv|zh-CN"]='{"results":[{"id":300,"name":"Show X"}]}'
local id
id=$(tv_search_by_dir "$f" "01" "1" "")
assert_eq "300" "$id" "目录名兜底命中"
}
# --- 完整识别链 ---
# identify_tv_show:zh 命中 → build_tv_dest 输出目标
test_identify_tv_show_zh_hit() {
MOCK_RESPONSES["/search/tv|zh-CN"]='{"results":[{"id":100,"name":"Show A"}]}'
MOCK_RESPONSES["/tv/100|zh-CN"]='{"name":"Show A","first_air_date":"2020-01-01","number_of_seasons":2,"seasons":[{"season_number":1},{"season_number":2}]}'
MOCK_RESPONSES["/tv/100/season/1|zh-CN"]='{"episodes":[{"episode_number":1,"name":"Pilot"},{"episode_number":2,"name":"Two"}]}'
local dest="" rc=0
dest=$(identify_tv_show "Show A" "1" "1" "" "Show A S01E01" "mkv" "" "$TEST_DIR/src/Show A/Show A S01E01.mkv") || rc=$?
assert_eq "0" "$rc" "zh 命中识别返回码"
assert_contains "$dest" "节目/Show A (2020)/Season 01|S01E01 - Pilot.mkv" "zh 命中目标"
}
# identify_tv_show:zh 空 → en 命中
test_identify_tv_show_zh_miss_en_hit() {
MOCK_RESPONSES["/search/tv|en-US"]='{"results":[{"id":200,"name":"Show B"}]}'
MOCK_RESPONSES["/tv/200|zh-CN"]='{"name":"Show B","first_air_date":"2021-01-01","number_of_seasons":1,"seasons":[{"season_number":1}]}'
MOCK_RESPONSES["/tv/200/season/1|zh-CN"]='{"episodes":[{"episode_number":1,"name":"First"}]}'
local dest="" rc=0
dest=$(identify_tv_show "Show B" "1" "1" "" "Show B S01E01" "mkv" "" "$TEST_DIR/src/Show B/Show B S01E01.mkv") || rc=$?
assert_eq "0" "$rc" "en 命中识别返回码"
assert_contains "$dest" "Show B (2021)" "en 命中目标"
}
# identify_tv_show:主搜索(zh)请求失败 → 短路 return 2(不试后续层)
test_identify_tv_show_zh_fail_shortcircuit() {
MOCK_FAIL_ENDPOINTS["/search/tv"]=1
MOCK_RESPONSES["/search/tv|en-US"]='{"results":[{"id":200,"name":"Show B"}]}'
local rc=0
identify_tv_show "Show B" "1" "1" "" "Show B S01E01" "mkv" "" "$TEST_DIR/src/Show B/Show B S01E01.mkv" >/dev/null 2>&1 || rc=$?
assert_eq "2" "$rc" "主搜索失败短路返回码"
}
# identify_tv_show:zh/en 空 → 后缀二次剥离重搜命中("Railgun S" → "Railgun")
test_identify_tv_show_suffix_strip() {
MOCK_RESPONSES["/search/tv|zh-CN"]='{"results":[{"id":400,"name":"Railgun"}]}'
MOCK_RESPONSES["/tv/400|zh-CN"]='{"name":"Railgun","first_air_date":"2020-01-01","number_of_seasons":3,"seasons":[{"season_number":1},{"season_number":2},{"season_number":3}]}'
MOCK_RESPONSES["/tv/400/season/1|zh-CN"]='{"episodes":[{"episode_number":1,"name":"First"}]}'
# 注意:首次搜索 "Railgun S" 无结果(mock 表无 "Railgun S" 条目),二次剥离后 "Railgun" 命中
local dest="" rc=0
dest=$(identify_tv_show "Railgun S" "1" "1" "" "Railgun S S01E01" "mkv" "" "$TEST_DIR/src/Railgun S/Railgun S S01E01.mkv") || rc=$?
assert_eq "0" "$rc" "后缀剥离识别返回码"
assert_contains "$dest" "Railgun (2020)" "后缀剥离命中目标"
}
# identify_tv_show:全链失败 → return 1(无结果)
test_identify_tv_show_all_miss() {
local rc=0
identify_tv_show "No Such Show" "1" "1" "" "No Such Show S01E01" "mkv" "" "$TEST_DIR/src/No Such Show/No Such Show S01E01.mkv" >/dev/null 2>&1 || rc=$?
assert_eq "1" "$rc" "全链无结果返回码"
}
# identify_movie:zh 命中 → build_movie_dest 输出目标
test_identify_movie_zh_hit() {
MOCK_RESPONSES["/search/movie|zh-CN"]='{"results":[{"id":50,"title":"Movie X","release_date":"2020-01-01"}]}'
MOCK_RESPONSES["/movie/50|zh-CN"]='{"title":"Movie X","release_date":"2020-01-01"}'
local dest="" rc=0
dest=$(identify_movie "Movie X" "mkv" "$TEST_DIR/src/Movie X (2020).mkv" "Movie X" "2020") || rc=$?
assert_eq "0" "$rc" "movie zh 命中返回码"
assert_contains "$dest" "电影/Movie X (2020)|Movie X (2020).mkv" "movie zh 命中目标"
}
# identify_movie:年份不符且无一致候选 → 需甄别(emit MATCH,返回 3)
test_identify_movie_year_mismatch_match() {
MOCK_RESPONSES["/search/movie|zh-CN"]='{"results":[{"id":50,"title":"Movie X","release_date":"2019-05-01"}]}'
local rc=0
identify_movie "Movie X" "mkv" "$TEST_DIR/src/Movie X (2020).mkv" "Movie X" "2020" >/dev/null 2>&1 || rc=$?
assert_eq "3" "$rc" "年份不符需甄别返回码"
assert_success test -n "${PENDING_AI_MATCH["$TEST_DIR/src/Movie X (2020).mkv"]:-}"
}
# identify_movie:主搜索失败 → 短路 return 2
test_identify_movie_zh_fail_shortcircuit() {
MOCK_FAIL_ENDPOINTS["/search/movie"]=1
local rc=0
identify_movie "Movie X" "mkv" "$TEST_DIR/src/Movie X (2020).mkv" "Movie X" "2020" >/dev/null 2>&1 || rc=$?
assert_eq "2" "$rc" "movie 主搜索失败短路返回码"
}
# identify_movie:zh 空 → en 命中(两行协议回传年份校验通过)
test_identify_movie_en_fallback() {
MOCK_RESPONSES["/search/movie|en-US"]='{"results":[{"id":60,"title":"Movie Y","release_date":"2021-01-01"}]}'
MOCK_RESPONSES["/movie/60|zh-CN"]='{"title":"Movie Y","release_date":"2021-01-01"}'
local dest="" rc=0
dest=$(identify_movie "Movie Y" "mkv" "$TEST_DIR/src/Movie Y (2021).mkv" "Movie Y" "2021") || rc=$?
assert_eq "0" "$rc" "movie en 命中返回码"
assert_contains "$dest" "Movie Y (2021)" "movie en 命中目标"
}
+75
View File
@@ -0,0 +1,75 @@
# ============================================================================
# Music Videos 类目测试(src/lib/media/filename.sh / identify.sh)
# ============================================================================
setup() {
TEST_DIR="$(mktemp -d)"
SOURCE_DIR="$TEST_DIR"
FOLDER_MUSICVIDEOS="音乐视频"
FOLDER_UNKNOWN="未知"
SPECIAL_WORDS_CAT=()
mapfile -t MUSICVIDEO_WORDS < <(echo "$MUSICVIDEO_WORDS_TEMPLATE" | jq -r '.[]')
mapfile -t SPECIAL_WORDS < <(echo "$SPECIAL_WORDS_TEMPLATE" | jq -r '.[]')
MAIN_COUNT_CACHE=()
VIDEO_EXTS=(mp4 mkv)
}
teardown() {
rm -rf "$TEST_DIR"
}
# detect_musicvideo:目录信号(Music Videos / MV / Live 目录 → musicvideo)
test_musicvideo_dir_signal() {
mkdir -p "$TEST_DIR/Music Videos/周杰伦"
assert_eq "musicvideo|晴天|||||" \
"$(parse_media_filename "$TEST_DIR/Music Videos/周杰伦/晴天 [MV].mkv")"
assert_eq "musicvideo|七里香|||||" \
"$(parse_media_filename "$TEST_DIR/Music Videos/周杰伦/七里香.mp4")"
mkdir -p "$TEST_DIR/Live"
assert_eq "musicvideo|演唱会|||||" \
"$(parse_media_filename "$TEST_DIR/Live/演唱会.mkv")"
}
# detect_musicvideo:文件名信号([MV] + "歌手 - 歌名" 模式 → musicvideo)
test_musicvideo_filename_signal() {
assert_eq "musicvideo|周杰伦 - 晴天|||||" \
"$(parse_media_filename "$TEST_DIR/周杰伦 - 晴天 [MV].mkv")"
assert_eq "musicvideo|歌手 - 歌|||||" \
"$(parse_media_filename "$TEST_DIR/歌手 - 歌 [Live].mkv")"
}
# detect_musicvideo:双命中歧义([MV] 无 " - " → 保持特典路径)
test_musicvideo_dual_hit_keeps_special() {
assert_eq "tv|某动画||0|1||MV" \
"$(parse_media_filename "$TEST_DIR/某动画 [MV].mkv")"
}
# detect_musicvideo:目录名含词但不精确匹配 → 不误判(Muv-Luv / tmp.xxx)
test_musicvideo_dir_exact_match_only() {
assert_eq "tv|Muv-Luv||01|01||" \
"$(parse_media_filename "$TEST_DIR/Muv-Luv S01E01.mkv")"
assert_eq "tv|Show||1|07||" \
"$(parse_media_filename "$TEST_DIR/Show [07].mkv")"
}
# identify_musicvideo:目录名解析 artist("歌手 - 歌名" 取首段;无分隔符整名)
test_identify_musicvideo_artist_from_dir() {
mkdir -p "$TEST_DIR/Music Videos/周杰伦"
assert_eq "音乐视频/周杰伦/晴天|晴天.mkv" \
"$(identify_musicvideo "$TEST_DIR/Music Videos/周杰伦/晴天.mkv" "晴天" mkv)"
mkdir -p "$TEST_DIR/歌手 - 歌名"
assert_eq "音乐视频/歌手/x|x.mkv" \
"$(identify_musicvideo "$TEST_DIR/歌手 - 歌名/x.mkv" "x" mkv)"
}
# identify_musicvideo:无元数据/目录线索 → FOLDER_UNKNOWN 兜底
test_identify_musicvideo_unknown_artist() {
assert_eq "音乐视频/未知/歌|歌.mkv" \
"$(identify_musicvideo "$TEST_DIR/歌.mkv" "歌" mkv)"
}
# fallback_naming:musicvideo 分支(Unknown 艺术家)
test_fallback_naming_musicvideo() {
assert_eq "音乐视频/未知/歌|歌.mkv" \
"$(fallback_naming "musicvideo" "歌" "" "" "" "" "" "mkv")"
}
+61
View File
@@ -0,0 +1,61 @@
# ============================================================================
# 硬链接基础工具测试(src/lib/link.sh)
# ============================================================================
setup() {
TEST_DIR="$(mktemp -d)"
DRY_RUN=false
}
teardown() {
rm -rf "$TEST_DIR"
}
# get_file_inode:GNU stat 与兜底路径均可用
test_get_file_inode_works() {
local f="$TEST_DIR/a.txt"
echo hi >"$f"
local inode
inode="$(get_file_inode "$f")"
assert_not_empty "$inode"
assert_eq "$(stat -c '%i' "$f")" "$inode" "inode 应与 stat 一致"
}
# same_inode:硬链接同 inode,普通文件不同
test_same_inode_detection() {
local f1="$TEST_DIR/a.txt" f2="$TEST_DIR/b.txt"
echo hi >"$f1"
ln "$f1" "$f2"
assert_success same_inode "$f1" "$f2"
echo other >"$TEST_DIR/c.txt"
assert_failure same_inode "$f1" "$TEST_DIR/c.txt"
assert_failure same_inode "$f1" "$TEST_DIR/不存在.txt"
}
# hardlink_or_dryrun:真实创建硬链接
test_hardlink_or_dryrun_creates_link() {
local f1="$TEST_DIR/a.txt" f2="$TEST_DIR/link.txt"
echo data >"$f1"
assert_success hardlink_or_dryrun "$f1" "$f2"
assert_file_exists "$f2"
assert_success same_inode "$f1" "$f2"
}
# hardlink_or_dryrun:干运行不创建
test_hardlink_or_dryrun_dry_run() {
local f1="$TEST_DIR/a.txt" f2="$TEST_DIR/link.txt"
echo data >"$f1"
DRY_RUN=true
assert_success hardlink_or_dryrun "$f1" "$f2"
assert_file_absent "$f2"
}
# hardlink_or_dryrun:目标已存在时替换
test_hardlink_or_dryrun_replaces_existing() {
local f1="$TEST_DIR/a.txt" f2="$TEST_DIR/exists.txt"
echo new >"$f1"
echo old >"$f2"
assert_success hardlink_or_dryrun "$f1" "$f2"
assert_success same_inode "$f1" "$f2"
assert_eq "new" "$(cat "$f2")"
}
+92
View File
@@ -0,0 +1,92 @@
# ============================================================================
# 识别登记与并发池模块测试(src/lib/registry.sh)
# ============================================================================
setup() {
MEDIA_DESTINATION_MAP=()
MEDIA_OUTCOME_MAP=()
PENDING_AI_SEARCH=()
PENDING_AI_SPECIAL=()
PENDING_AI_ARTIST=()
PENDING_AI_MATCH=()
PENDING_SEARCH_COUNT=0
PENDING_SPECIAL_COUNT=0
PENDING_ARTIST_COUNT=0
PENDING_MATCH_COUNT=0
LINK_SUCCESS_COUNT=0
LINK_FAIL_COUNT=0
}
# register_identified:登记目的与结局账本
test_register_identified() {
register_identified "/m/a.mkv" "Movie/A (2020)" "A (2020).mkv"
assert_eq "Movie/A (2020)|A (2020).mkv" "${MEDIA_DESTINATION_MAP[/m/a.mkv]}"
assert_eq "identified" "${MEDIA_OUTCOME_MAP[/m/a.mkv]}"
}
# register_skip:跳过不进入目的映射
test_register_skip() {
register_skip "/m/a.mkv" "skip_unidentified"
assert_eq "skip_unidentified" "${MEDIA_OUTCOME_MAP[/m/a.mkv]}"
assert_empty "${MEDIA_DESTINATION_MAP[/m/a.mkv]:-}"
}
# register_request_failed:请求失败结局
test_register_request_failed() {
register_request_failed "/m/a.mkv"
assert_eq "request_failed" "${MEDIA_OUTCOME_MAP[/m/a.mkv]}"
}
# register_pending:待 AI 搜索并计入计数
test_register_pending() {
register_pending "/m/a.mkv" "unknown dir"
assert_eq "unknown dir" "${PENDING_AI_SEARCH[/m/a.mkv]}"
assert_eq "1" "$PENDING_SEARCH_COUNT"
}
# emit:DEST 协议行写入 POOL_EMIT_FILE(识别池 worker 模式;父进程 merge 后登记)
test_emit_dest_protocol() {
local f
f="$(mktemp)"
POOL_EMIT_FILE="$f"
emit "/m/a.mkv" DEST "Movie/A (2020)|A (2020).mkv"
assert_eq $'/m/a.mkv DEST Movie/A (2020)|A (2020).mkv' "$(cat "$f")"
assert_empty "${MEDIA_OUTCOME_MAP[/m/a.mkv]:-}" "emit 本身不登记,merge 才登记"
rm -f "$f"
unset POOL_EMIT_FILE
}
# emit:SPECIAL_PENDING 直接登记待学习特典
test_emit_special_pending() {
emit "/m/a.mkv" SPECIAL_PENDING "123|Menu01|Show|01:Menu; 02:CM"
assert_eq "Show|01:Menu; 02:CM" "${PENDING_AI_SPECIAL[123|Menu01]}"
assert_eq "1" "$PENDING_SPECIAL_COUNT"
}
# emit:MATCH 直接登记待甄别
test_emit_match_protocol() {
emit "/m/a.mkv" MATCH "tv 1 /search/tv key123"
assert_eq "1" "$PENDING_MATCH_COUNT"
assert_not_empty "${PENDING_AI_MATCH[/m/a.mkv]:-}"
}
# merge_emit_lines:合并子进程协议行(临时文件)
test_merge_emit_lines() {
local f
f="$(mktemp)"
printf '/m/a.mkv\tDEST\tMovie/A|A.mkv\n' >"$f"
printf '/m/b.mkv\tSKIP\tskip_type\n' >>"$f"
merge_emit_lines "$f"
rm -f "$f"
assert_eq "identified" "${MEDIA_OUTCOME_MAP[/m/a.mkv]}"
assert_eq "skip_type" "${MEDIA_OUTCOME_MAP[/m/b.mkv]}"
}
# dir_chain:相对源根的目录链(最多 3 层)
test_dir_chain() {
SOURCE_DIR="/media/src"
assert_eq "Show/Season 1" "$(dir_chain "/media/src/Show/Season 1/a.mkv")"
# 契约:最多 3 层,保留最靠近文件的 3 层(含文件所在目录)
assert_eq "B/C/D" "$(dir_chain "/media/src/A/B/C/D/a.mkv")"
assert_eq "src" "$(dir_chain "/media/src/a.mkv")"
}
+67
View File
@@ -0,0 +1,67 @@
# ============================================================================
# 缓存管理模块测试(src/lib/cache.sh)
# ============================================================================
setup() {
CACHE_DIR="$(mktemp -d)"
TMDB_LANG="zh-CN"
}
teardown() {
rm -rf "$CACHE_DIR"
}
# cache_path:搜索类用 md5 哈希文件名
test_cache_path_search_endpoint() {
local p
p="$(cache_path "/search/movie" "query=刀剑&year=2020")"
assert_eq "$CACHE_DIR/tmdb/search/movie/$(cache_key_hash "query=刀剑&year=2020").json" "$p"
}
# cache_path:tv 详情含语言段
test_cache_path_tv_detail() {
assert_eq "$CACHE_DIR/tmdb/tv/12345.zh-CN.json" "$(cache_path "/tv/12345" "x")"
}
# cache_path:季数据含语言段
test_cache_path_tv_season() {
assert_eq "$CACHE_DIR/tmdb/tv/12345/season/1.zh-CN.json" "$(cache_path "/tv/12345/season/1" "x")"
}
# cache_path:movie 详情含语言段
test_cache_path_movie_detail() {
assert_eq "$CACHE_DIR/tmdb/movie/6789.zh-CN.json" "$(cache_path "/movie/6789" "x")"
}
# cache_path:语言切换后路径变化(缓存自动失效)
test_cache_path_lang_switch_changes_path() {
TMDB_LANG="ja-JP"
assert_eq "$CACHE_DIR/tmdb/tv/12345.ja-JP.json" "$(cache_path "/tv/12345" "x")"
}
# cache_key:拼接参数并追加 lang
test_cache_key_joins_args_with_lang() {
assert_eq "query=刀剑&lang=zh-CN" "$(cache_key "query=刀剑")"
assert_eq "a=1&b=2&lang=zh-CN" "$(cache_key "a=1" "&b=2")"
}
# cache_key_hash:md5 稳定
test_cache_key_hash_stable() {
local h1 h2
h1="$(cache_key_hash "query=hello")"
h2="$(cache_key_hash "query=hello")"
assert_eq "$h1" "$h2"
assert_eq "5d41402abc4b2a76b9719d911017c592" "$(cache_key_hash "hello")"
}
# cache_expired:TTL 内不失效,超期失效
test_cache_expired_ttl() {
local f="$CACHE_DIR/f.json"
echo '{}' >"$f"
touch -d "2 days ago" "$f"
assert_success cache_expired "$f" 1
assert_failure cache_expired "$f" 30
# 新文件不失效
echo '{}' >"$f"
assert_failure cache_expired "$f" 1
}
+96
View File
@@ -0,0 +1,96 @@
# ============================================================================
# 账本与冷却模块测试(src/lib/ledger.sh)
# ============================================================================
setup() {
TEST_DIR="$(mktemp -d)"
CACHE_DIR="$TEST_DIR/cache"
DRY_RUN=false
FAIL_RETRY_COOLDOWN_HOURS=1
LEDGER_LINKED=()
LEDGER_INODE=()
COOLDOWN_MAP=()
CORRECTIONS_MAP=()
}
teardown() {
rm -rf "$TEST_DIR"
}
# ledger_mark_linked + ledger_is_linked:标记后判定已链接
test_ledger_mark_and_is_linked() {
local src="$TEST_DIR/a.mkv" dest="$TEST_DIR/dest/a.mkv"
mkdir -p "$TEST_DIR/dest"
echo data >"$src"
ln "$src" "$dest"
ledger_mark_linked "$src" "$dest"
assert_success ledger_is_linked "$src"
}
# ledger_is_linked:目标被删 → 失效(重新识别)
test_ledger_is_linked_invalid_when_dest_deleted() {
local src="$TEST_DIR/a.mkv" dest="$TEST_DIR/dest/a.mkv"
mkdir -p "$TEST_DIR/dest"
echo data >"$src"
ln "$src" "$dest"
ledger_mark_linked "$src" "$dest"
rm -f "$dest"
assert_failure ledger_is_linked "$src"
}
# ledger_is_linked:未标记 → 未链接
test_ledger_is_linked_unmarked() {
local src="$TEST_DIR/a.mkv"
echo data >"$src"
assert_failure ledger_is_linked "$src"
}
# cooldown_set + cooldown_active:冷却期内判定
test_cooldown_set_and_active() {
local src="$TEST_DIR/a.mkv"
echo data >"$src"
cooldown_set "$src" "request_failed"
assert_success cooldown_active "$src"
}
# cooldown_active:源文件消失 → 不冷却(允许重试)
test_cooldown_inactive_when_src_gone() {
local src="$TEST_DIR/a.mkv"
echo data >"$src"
cooldown_set "$src" "request_failed"
rm -f "$src"
assert_failure cooldown_active "$src"
}
# cooldown_active:过期 → 不冷却
test_cooldown_inactive_when_expired() {
local src="$TEST_DIR/a.mkv"
echo data >"$src"
COOLDOWN_MAP["$src"]=$(($(date +%s) - 10))
assert_failure cooldown_active "$src"
}
# ledger_flush:落盘 linked.json 并可重新加载
test_ledger_flush_roundtrip() {
local src="$TEST_DIR/a.mkv" dest="$TEST_DIR/dest/a.mkv"
mkdir -p "$TEST_DIR/dest"
echo data >"$src"
ln "$src" "$dest"
ledger_mark_linked "$src" "$dest"
ledger_flush
assert_file_exists "$CACHE_DIR/media_organizer/linked.json"
LEDGER_LINKED=()
LEDGER_INODE=()
ledger_load
assert_success ledger_is_linked "$src"
}
# ledger_flush:干运行零持久化副作用
test_ledger_flush_skipped_in_dry_run() {
local src="$TEST_DIR/a.mkv"
echo data >"$src"
ledger_mark_linked "$src" "$TEST_DIR/dest.mkv"
DRY_RUN=true
ledger_flush
assert_file_absent "$CACHE_DIR/media_organizer/linked.json"
}
+90
View File
@@ -0,0 +1,90 @@
# ============================================================================
# 字符串工具模块测试(src/lib/strings.sh)
# ============================================================================
# 每个测试前重置依赖的全局状态
setup() {
COLOR_OUTPUT=false
TMDB_LANG="zh-CN"
SPECIAL_MAP=()
}
# sanitize:删除非法字符
test_sanitize_removes_illegal_chars() {
assert_eq "abcdefghij" "$(sanitize 'a/b\c:d*e?f"g<h>i|j')" "sanitize 应删除 \\ / : * ? \" < > |"
}
# sanitize:保留中文与空格
test_sanitize_keeps_cjk_and_spaces() {
assert_eq "某科学的超电磁炮T" "$(sanitize '某科学的超电磁炮T')"
assert_eq "刀剑神域 爱丽丝篇" "$(sanitize '刀剑神域 爱丽丝篇')"
}
# sanitize:空输入
test_sanitize_empty_input() {
assert_eq "" "$(sanitize "")"
assert_eq "" "$(sanitize "///")"
}
# clean_name:去方括号标记与首尾空白
test_clean_name_strips_brackets_and_spaces() {
assert_eq "Show Title" "$(clean_name '[VCB-Studio] Show Title [1080p]')"
assert_eq "Title" "$(clean_name ' [A] Title [B] ')"
}
# strip_season_suffix:多种季后缀
test_strip_season_suffix_variants() {
assert_eq "Show" "$(strip_season_suffix 'Show S01')"
assert_eq "Show" "$(strip_season_suffix 'Show 2nd Season')"
assert_eq "Show" "$(strip_season_suffix 'Show S12')"
# 行为契约:仅剥离 Season 前带数字的形式;"Show Season 2" 由 parse_media_filename 处理
assert_eq "Show Season 2" "$(strip_season_suffix 'Show Season 2')"
assert_eq "无需处理" "$(strip_season_suffix '无需处理')"
}
# norm_str:全角转半角 + 去空格/连字符 + 小写
test_norm_str_fullwidth_to_halfwidth() {
assert_eq "aliceization" "$(norm_str 'Aliceization')"
assert_eq "swordartonline" "$(norm_str 'Sword Art Online')"
assert_eq "iiiiiivv" "$(norm_str 'ⅡⅢⅣⅤ')"
assert_eq "menu01" "$(norm_str 'Menu 01')"
}
# safe_printf_int:补零与非数字默认值
test_safe_printf_int_padding() {
assert_eq "01" "$(safe_printf_int 1)"
assert_eq "23" "$(safe_printf_int 23)"
assert_eq "00" "$(safe_printf_int "")"
assert_eq "99" "$(safe_printf_int "abc" "99")"
assert_eq "07" "$(safe_printf_int "007")"
}
# normalize_abs:相对/绝对路径归一化
test_normalize_abs_paths() {
local d
d="$(mktemp -d)"
assert_eq "$d" "$(normalize_abs "$d")" "绝对路径原样返回"
assert_eq "$d/sub" "$(normalize_abs "$d/sub")" "不存在的子目录也应归一化"
assert_eq "" "$(normalize_abs "")" "空串原样返回"
rm -rf "$d"
}
# is_companion:伴随文件判断
test_is_companion_detection() {
assert_success is_companion "/path/Show.S01E01.mkv.ass" "/path/Show.S01E01.mkv"
assert_success is_companion "/path/Show.S01E01.zh-CN.ass" "/path/Show.S01E01"
assert_success is_companion "/path/Show.S01E01.flac" "/path/Show.S01E01"
assert_failure is_companion "/path/Other.mkv" "/path/Show.S01E01"
# 行为契约:视频自身去扩展名后等于 vbase → 也判为伴随(link_companions 调用前先跳过自身)
assert_success is_companion "/path/Show.S01E01.mkv" "/path/Show.S01E01"
}
# translate_fragment:查表、核心词回退、归一化
test_translate_fragment_lookup() {
SPECIAL_MAP["menu"]='["Menu", "菜单"]'
SPECIAL_MAP["cm01"]='["CM", "广告"]'
assert_eq "Menu" "$(translate_fragment "Menu01")" "去数字后查核心词"
assert_eq "CM" "$(translate_fragment "CM01")" "精确键优先"
assert_eq "Menu" "$(translate_fragment "Menu01")" "全角归一化"
assert_eq "PV-01" "$(translate_fragment "PV-01")" "无映射时原样返回"
}