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:
1 parent
d978a13a83
commit
0df07ad0ae
48 files changed
+5813
-6812
No files matched your search
@@ -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 "未知")"
|
||||
}
|
||||
@@ -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[影]:-}"
|
||||
}
|
||||
@@ -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")"
|
||||
}
|
||||
@@ -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 '俺妹')"
|
||||
}
|
||||
@@ -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[@]}"
|
||||
}
|
||||
@@ -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")"
|
||||
}
|
||||
@@ -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")"
|
||||
}
|
||||
@@ -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 命中目标"
|
||||
}
|
||||
@@ -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")"
|
||||
}
|
||||
@@ -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")"
|
||||
}
|
||||
@@ -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")"
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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")" "无映射时原样返回"
|
||||
}
|
||||
Reference in new issue
Block a user