From 3fec74f772c3efede715b49c359a64d9e95e8168 Mon Sep 17 00:00:00 2001 From: Levon Gevorgyan Date: Tue, 28 Apr 2026 18:38:20 -0500 Subject: [PATCH] add coverage --- .gitignore | 3 +- test-add-synced-lyrics.sh | 282 +++++++++++++++++++++++++++++++------- 2 files changed, 234 insertions(+), 51 deletions(-) diff --git a/.gitignore b/.gitignore index c29095a..37db696 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ test-dir* -*.json \ No newline at end of file +*.json +coverage* \ No newline at end of file diff --git a/test-add-synced-lyrics.sh b/test-add-synced-lyrics.sh index 0686054..52be64e 100755 --- a/test-add-synced-lyrics.sh +++ b/test-add-synced-lyrics.sh @@ -3,35 +3,122 @@ # "${test}" invokes test_ functions # shellcheck disable=SC2329 -set -u +set -eu -readonly THIS_DIR="$(dirname "$(readlink -f "$0")")" -readonly TEST_DIR="${THIS_DIR}/test-dir" +bash_basename() { + local tmp + path="${1:-}" + suffix="${2:-''}" + + tmp=${path%"${path##*[!/]}"} + tmp=${tmp##*/} + tmp=${tmp%"${suffix/"$tmp"/}"} + + printf '%s\n' "${tmp:-/}" +} + +bash_dirname() { + # Usage: dirname "path" + local tmp=${1:-.} + + [[ $tmp != *[!/]* ]] && { + printf '/\n' + return + } + + tmp=${tmp%%"${tmp##*[!/]}"} + + [[ $tmp != */* ]] && { + printf '.\n' + return + } + + tmp=${tmp%/*} + tmp=${tmp%%"${tmp##*[!/]}"} + + printf '%s\n' "${tmp:-/}" +} + +readonly PROGPATH="$(readlink -f "$0")" +readonly PROGDIR="$(bash_dirname "${PROGPATH}")" +readonly PROGNAME="$(bash_basename "${PROGPATH}")" + +# to normalize paths +cd "${PROGDIR}" + +readonly TEST_DIR="test-dir" # lyrics exist for this song readonly GOOD_SONG="${TEST_DIR}/back burner.opus" readonly GOOD_LYRICS="${GOOD_SONG//.opus/.lrc}" # lyrics do not exist for this song readonly BAD_SONG="${TEST_DIR}/subdir/avalanche.opus" readonly BAD_LYRICS="${BAD_SONG//.opus/.lrc}" -readonly PROG="${THIS_DIR}/add-synced-lyrics.sh" +readonly PROG="add-synced-lyrics.sh" +# coverage +readonly COVERAGE_DIR="coverage" +readonly COVERAGE_FILE="${COVERAGE_DIR}/coverage" + +run_test_cmd() { + local expectedRetval="${1:-}" + + shift 1 + local cmd=("$@") + + # check for empty args for setup tests to run + [[ -z "${expectedRetval}" && -z "${cmd[*]}" ]] && return + + # check a value was given + if [[ ! "${expectedRetval}" =~ [0-9] ]]; then + echo "expectedRetval not given" + exit 1 + fi + + local output + output="$("${cmd[@]}")" + local actualRetval=$? + + declare -g CMD_OUTPUT="${output}" + declare -g CMD_RETURN="${actualRetval}" + if [[ ${expectedRetval} -ne ${actualRetval} ]]; then + echo "${expectedRetval} != ${actualRetval} for ${cmd[*]}" + return 1 + else + return 0 + fi + +} test_bad_input() { # incomplete args - "${PROG}" && return 1 - "${PROG}" --file && return 1 - "${PROG}" --dir && return 1 - "${PROG}" --recurse && return 1 + run_test_cmd \ + 1 \ + add-synced-lyrics + run_test_cmd \ + 1 \ + add-synced-lyrics --file + run_test_cmd \ + 1 \ + add-synced-lyrics --dir + run_test_cmd \ + 1 \ + add-synced-lyrics --recurse # incorrect mixmatch - "${PROG}" --file "${PROG}" --recurse && return 1 - "${PROG}" --file "${PROG}" --dir "${THIS_DIR}" && return 1 - "${PROG}" --file "${PROG}" --dir "${THIS_DIR}" --recurse && return 1 + run_test_cmd \ + 1 \ + add-synced-lyrics --file "${PROGPATH}" --recurse + run_test_cmd \ + 1 \ + add-synced-lyrics --file "${PROGPATH}" --dir "${PROGDIR}" + run_test_cmd \ + 1 \ + add-synced-lyrics --file "${PROGPATH}" --dir "${PROGDIR}" --recurse return 0 } test_determine_inputs() ( - eval "$("${PROG}" --eval)" + eval "$(add-synced-lyrics --eval)" set_default_options DIR="${TEST_DIR}" @@ -48,12 +135,12 @@ test_determine_inputs() ( echo "comparing bash vs fd" diff \ <(printf '%s\n' "${bashInputs[@]}") \ - <(printf '%s\n' "${fdInputs[@]}") || return 1 + <(printf '%s\n' "${fdInputs[@]}") echo "comparing fd vs find" diff \ <(printf '%s\n' "${fdInputs[@]}") \ - <(printf '%s\n' "${findInputs[@]}") || return 1 + <(printf '%s\n' "${findInputs[@]}") return 0 ) @@ -63,47 +150,55 @@ test_determine_inputs_recurse() { } test_file_dry() { - "${PROG}" --file "${GOOD_SONG}" --dry-run + add-synced-lyrics --file "${GOOD_SONG}" --dry-run } test_directory_dry() { - "${PROG}" --dir "${TEST_DIR}" --dry-run + add-synced-lyrics --dir "${TEST_DIR}" --dry-run } test_directory_recurse_dry() { - "${PROG}" --dir "${TEST_DIR}" --recurse --dry-run + add-synced-lyrics --dir "${TEST_DIR}" --recurse --dry-run } test_good_file() { - "${PROG}" --file "${GOOD_SONG}" || return 1 + add-synced-lyrics --file "${GOOD_SONG}" test -f "${GOOD_LYRICS}" } test_bad_file() { - "${PROG}" --file "${BAD_SONG}" && return 1 - ! test -f "${BAD_LYRICS}" + run_test_cmd \ + 1 \ + add-synced-lyrics --file "${BAD_SONG}" + run_test_cmd \ + 1 \ + test -f "${BAD_LYRICS}" } test_directory_recurse() { - setup_test_data || return 1 + setup_test_data # this test should fail since it includes the bad file - "${PROG}" --dir "${TEST_DIR}" --recurse && return 1 - - return 0 + run_test_cmd \ + 1 \ + add-synced-lyrics --dir "${TEST_DIR}" --recurse } test_directory_recurse_ignore() { - setup_test_data || return 1 - "${PROG}" --dir "${TEST_DIR}" --recurse --ignore && return 1 - test -f "${GOOD_LYRICS}" || return 1 - test -f "${BAD_LYRICS}" && return 1 + setup_test_data + run_test_cmd \ + 1 \ + add-synced-lyrics --dir "${TEST_DIR}" --recurse --ignore + test -f "${GOOD_LYRICS}" + run_test_cmd \ + 1 \ + test -f "${BAD_LYRICS}" return 0 } setup_test_data() { test -d "${TEST_DIR}" && rm -rf "${TEST_DIR}" - mkdir -p "${TEST_DIR}/subdir" || return 1 + mkdir -p "${TEST_DIR}/subdir" ffmpegCmd=( ffmpeg -hide_banner @@ -114,14 +209,13 @@ setup_test_data() { ) "${ffmpegCmd[@]}" \ -metadata ISRC=USTN10700139 \ - "${GOOD_SONG}" || return 1 + "${GOOD_SONG}" "${ffmpegCmd[@]}" \ -metadata ISRC=US5261822978 \ - "${BAD_SONG}" || return 1 + "${BAD_SONG}" } TESTS=( - setup_test_data test_determine_inputs test_determine_inputs_recurse test_bad_input @@ -136,27 +230,115 @@ TESTS=( # make sure no test is accidentally missed while read -r line; do - IFS=' ' read -r _ _ test <<<"${line}" - if [[ "${TESTS[*]}" != *"${test}"* ]]; then - echo "missing ${test} from \$TESTS" + IFS=' ' read -r _ _ function <<<"${line}" + if [[ "${TESTS[*]}" != *"${function}"* && + "${function}" == 'test_'* ]]; then + echo "missing ${function} from \$TESTS" exit 1 fi done <<<"$(declare -F)" -for test in "${TESTS[@]}"; do - if ! testOutput="$( - ( - set -x - "${test}" - ) 2>&1 - )"; then - echo - echo "!! ${test} failed !!" - echo "${testOutput}" - exit 1 - else - echo "${test} passed" +# generate the coverage function for use with eval +# to not redefine constants +gen_coverage_function() { + # shellcheck disable=SC2016 + echo 'coverage_function () { + local source line + source="${BASH_SOURCE[1]}" '" + source=\"\${source//'${PROGDIR}/'/}\" "' + line="${BASH_LINENO[0]}" + if [[ "${source}" == *".sh" && + "${source}" != *"'"${PROGNAME}"'" ]]; then + { + # minus one because shebang is not included + echo "${source} $((line - 1))"' ">>${COVERAGE_FILE}" ' + } fi -done +}' +} -exit 0 +setup_coverage() { + test -d "${COVERAGE_DIR}" && rm -rf "${COVERAGE_DIR}" + mkdir "${COVERAGE_DIR}" + : >"${COVERAGE_FILE}" + eval "$(gen_coverage_function)" +} + +add-synced-lyrics() { + export -f coverage_function + PS4='$(coverage_function)' bash -x "${PROG}" "$@" +} + +line_is_covered() { + local line="$1" + local lineNum="$2" + + # empty lines are covered + [[ "${#line}" -eq 0 ]] && return 0 + + # commented lines are covered + read -r firstToken _ <<<"${line}" + [[ "${firstToken}" == '#'* ]] && return 0 + + # line is explicitly in coverage file is covered + grep -q "${coverageFile} ${lineNum}" "${COVERAGE_FILE}" && return 0 + + return 1 +} + +analyze_coverage() { + sort -u "${COVERAGE_FILE}" >"${COVERAGE_FILE}.tmp" + mv "${COVERAGE_FILE}.tmp" "${COVERAGE_FILE}" + + mapfile -t coverageFiles < <(cut -d ' ' -f1 "${COVERAGE_FILE}" | sort -u) + + local RED='\e[0;31m' GREEN='\e[0;32m' NC='\e[0m' + + local fileDir fileLen color line linesCovered + for coverageFile in "${coverageFiles[@]}"; do + fileDir="${COVERAGE_DIR}/$(bash_dirname "${coverageFile}")" + test -d "${fileDir}" || mkdir -p "${fileDir}" + mapfile -t fileContents <"${coverageFile}" + fileLen="${#fileContents[@]}" + linesCovered=0 + { + for ((line = 0; line < fileLen; line++)); do + if line_is_covered "${fileContents[${line}]}" "${line}"; then + color="${GREEN}" + linesCovered=$((linesCovered + 1)) + else + color="${RED}" + fi + printf '%b%s%b\n' \ + "${color}" \ + "${fileContents[${line}]}" \ + "${NC}" + done + } >"${COVERAGE_DIR}/${coverageFile}" + echo "${coverageFile} lines covered: $((linesCovered * 100 / fileLen))%" + done +} + +if [[ $# -eq 0 ]]; then + setup_coverage + + for test in "${TESTS[@]}"; do + if ! testOutput="$( + ( + set -x + "${test}" + ) 2>&1 + )"; then + echo + echo "!! ${test} failed !!" + echo "${testOutput}" + exit 1 + else + echo "${test} passed" + fi + done + + analyze_coverage +else + "$@" +fi