# ============================================================================ # 识别搜索链测试(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 命中目标" }