aboutsummaryrefslogtreecommitdiff
path: root/tests/normalize-notify
diff options
context:
space:
mode:
Diffstat (limited to 'tests/normalize-notify')
-rw-r--r--tests/normalize-notify/fake-ffmpeg25
-rw-r--r--tests/normalize-notify/test_normalize_notify_sounds.py117
2 files changed, 142 insertions, 0 deletions
diff --git a/tests/normalize-notify/fake-ffmpeg b/tests/normalize-notify/fake-ffmpeg
new file mode 100644
index 0000000..cdfcb6f
--- /dev/null
+++ b/tests/normalize-notify/fake-ffmpeg
@@ -0,0 +1,25 @@
+#!/bin/bash
+# Fake ffmpeg for the normalize-notify-sounds tests. Two modes:
+# measure (args include the `volumedetect` filter): print a mean_volume line
+# to stderr, driven by FAKE_MEAN (default -20.0).
+# encode (otherwise): write to the output file (the last argument), driven
+# by FAKE_FFMPEG_EMPTY (1 -> zero-byte output) and FAKE_FFMPEG_FAIL
+# (1 -> exit non-zero without writing).
+set -uo pipefail
+
+for a in "$@"; do
+ if [ "$a" = "volumedetect" ]; then
+ echo "mean_volume: ${FAKE_MEAN:--20.0} dB" >&2
+ exit 0
+ fi
+done
+
+out="${*: -1}"
+if [ "${FAKE_FFMPEG_FAIL:-0}" = 1 ]; then
+ exit 1
+fi
+if [ "${FAKE_FFMPEG_EMPTY:-0}" = 1 ]; then
+ : > "$out"
+else
+ echo ENCODED > "$out"
+fi
diff --git a/tests/normalize-notify/test_normalize_notify_sounds.py b/tests/normalize-notify/test_normalize_notify_sounds.py
new file mode 100644
index 0000000..ce3084b
--- /dev/null
+++ b/tests/normalize-notify/test_normalize_notify_sounds.py
@@ -0,0 +1,117 @@
+"""Tests for normalize-notify-sounds.sh temp handling and atomic write.
+
+The script re-encodes each .ogg in place. SOUND_DIR is often the stow-symlinked
+~/.local copy, so the write must land on the real repo file and preserve the
+symlink. Two bugs the fix addresses:
+
+- A failed or empty re-encode used to truncate the target with `cat "$tmp" >
+ "$f"`, corrupting a repo-tracked sound to zero bytes.
+- The mktemp had no EXIT trap, so an interrupted encode leaked a temp file.
+
+ffmpeg/ffprobe are faked via stubs on PATH (this directory) so the encode
+result is controllable without real audio work.
+
+Run from repo root:
+ python3 -m unittest tests.normalize-notify.test_normalize_notify_sounds
+"""
+
+import glob
+import os
+import shutil
+import subprocess
+import tempfile
+import unittest
+
+HERE = os.path.dirname(os.path.abspath(__file__))
+REPO_ROOT = os.path.abspath(os.path.join(HERE, "..", ".."))
+SCRIPT = os.path.join(REPO_ROOT, "scripts", "normalize-notify-sounds.sh")
+
+
+class NormalizeNotifySounds(unittest.TestCase):
+ def setUp(self):
+ self.tmp = tempfile.mkdtemp(prefix="norm-notify-test-")
+ self.addCleanup(shutil.rmtree, self.tmp, ignore_errors=True)
+ self.bindir = os.path.join(self.tmp, "bin")
+ os.mkdir(self.bindir)
+ for tool in ("ffmpeg", "ffprobe"):
+ fake = os.path.join(HERE, "fake-" + tool)
+ if not os.path.exists(fake):
+ fake = os.path.join(HERE, "fake-ffmpeg") # ffprobe reuses stub
+ dst = os.path.join(self.bindir, tool)
+ shutil.copy(fake, dst)
+ os.chmod(dst, 0o755)
+ # sound dir with one .ogg, plus a "repo" the sound symlinks into
+ self.repo = os.path.join(self.tmp, "repo")
+ os.mkdir(self.repo)
+ self.sounddir = os.path.join(self.tmp, "sounds")
+ os.mkdir(self.sounddir)
+
+ def run_script(self, env_extra=None):
+ env = dict(os.environ)
+ env["PATH"] = self.bindir + os.pathsep + env["PATH"]
+ if env_extra:
+ env.update(env_extra)
+ return subprocess.run(
+ ["bash", SCRIPT, self.sounddir],
+ capture_output=True, text=True, timeout=20, env=env,
+ )
+
+ def make_sound(self, name="chime.ogg", body="ORIGINAL\n", symlink=False):
+ if symlink:
+ real = os.path.join(self.repo, name)
+ with open(real, "w") as f:
+ f.write(body)
+ link = os.path.join(self.sounddir, name)
+ os.symlink(real, link)
+ return link, real
+ path = os.path.join(self.sounddir, name)
+ with open(path, "w") as f:
+ f.write(body)
+ return path, path
+
+ def leftover_temps(self, directory):
+ return glob.glob(os.path.join(directory, "*.ogg.*")) + \
+ [p for p in glob.glob(os.path.join(directory, "tmp*")) if p.endswith(".ogg")]
+
+ # --- Normal ----------------------------------------------------------
+
+ def test_reencodes_regular_file(self):
+ path, _ = self.make_sound()
+ r = self.run_script()
+ self.assertEqual(r.returncode, 0, r.stderr)
+ with open(path) as f:
+ self.assertEqual(f.read(), "ENCODED\n")
+
+ def test_preserves_symlink_and_updates_target(self):
+ link, real = self.make_sound(symlink=True)
+ r = self.run_script()
+ self.assertEqual(r.returncode, 0, r.stderr)
+ self.assertTrue(os.path.islink(link), "the stow symlink must survive")
+ with open(real) as f:
+ self.assertEqual(f.read(), "ENCODED\n",
+ "the re-encode must land on the real repo file")
+
+ # --- Error / corruption guard ---------------------------------------
+
+ def test_empty_reencode_does_not_corrupt_target(self):
+ path, _ = self.make_sound(body="ORIGINAL\n")
+ self.run_script(env_extra={"FAKE_FFMPEG_EMPTY": "1"})
+ with open(path) as f:
+ self.assertEqual(f.read(), "ORIGINAL\n",
+ "an empty re-encode must not truncate the tracked file")
+
+ def test_failed_encode_leaves_no_temp_in_target_dir(self):
+ self.make_sound(symlink=True)
+ self.run_script(env_extra={"FAKE_FFMPEG_FAIL": "1"})
+ self.assertEqual(self.leftover_temps(self.repo), [],
+ "a failed encode must not leak a temp file")
+
+ def test_failed_encode_does_not_corrupt_target(self):
+ _, real = self.make_sound(symlink=True, body="ORIGINAL\n")
+ self.run_script(env_extra={"FAKE_FFMPEG_FAIL": "1"})
+ with open(real) as f:
+ self.assertEqual(f.read(), "ORIGINAL\n")
+
+
+if __name__ == "__main__":
+ unittest.main()