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 '俺妹')"
}