26f3abab72
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 2s
CI/CD Pipeline / Check push changed paths (push) Successful in 18s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 12s
CI/CD Pipeline / Build Staging API Image (push) Successful in 1m17s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 1m23s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m22s
CI/CD Pipeline / Validate - Style (push) Successful in 3m41s
CI/CD Pipeline / Integration Tests (push) Successful in 4m1s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 4m16s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 4m27s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m36s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1m42s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m24s
CI/CD Pipeline / Validate - Security (push) Successful in 8m48s
CI/CD Pipeline / Unit Tests (push) Successful in 9m33s
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Failing after 44h58m30s
CI/CD Pipeline / Retag skipped Staging Web Image (push) Failing after 45h6m21s
CI/CD Pipeline / PR Build Worker Image (push) Failing after 45h8m6s
CI/CD Pipeline / PR Build Web Image (push) Failing after 45h7m38s
CI/CD Pipeline / PR Build API Image (push) Failing after 45h7m39s
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Failing after 45h5m53s
CI/CD Pipeline / Retag skipped Staging API Image (push) Failing after 45h5m53s
CI/CD Pipeline / CI Gate (push) Failing after 44h58m2s
CI/CD Pipeline / Deploy Production (push) Failing after 44h57m58s
CI/CD Pipeline / Build Production API Image (push) Failing after 44h58m2s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 45h7m40s
CI/CD Pipeline / Canary Release to Production (push) Failing after 44h57m58s
CI/CD Pipeline / Build Production Web Image (push) Failing after 44h58m2s
CI/CD Pipeline / Frontend Lint (push) Failing after 45h7m34s
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com> Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
185 lines
8.1 KiB
Python
185 lines
8.1 KiB
Python
"""Tests for sentence timing functions (now in packages/domain/sentence_timings.py)."""
|
|
|
|
import os
|
|
import subprocess
|
|
import tempfile
|
|
import unittest
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
from packages.domain.sentence_timings import compute_sentence_timings as _compute_sentence_timings
|
|
from packages.domain.sentence_timings import estimate_sentence_timings_by_chars as _estimate_sentence_timings_by_chars
|
|
from packages.domain.sentence_timings import split_script_into_sentences as _split_script_into_sentences
|
|
|
|
|
|
class TestSplitScriptIntoSentences(unittest.TestCase):
|
|
"""Tests for _split_script_into_sentences."""
|
|
|
|
def test_empty_string(self):
|
|
self.assertEqual(_split_script_into_sentences(""), [])
|
|
|
|
def test_none(self):
|
|
self.assertEqual(_split_script_into_sentences(None), [])
|
|
|
|
def test_whitespace_only(self):
|
|
self.assertEqual(_split_script_into_sentences(" \n "), [])
|
|
|
|
def test_single_sentence(self):
|
|
self.assertEqual(_split_script_into_sentences("你好世界。"), ["你好世界"])
|
|
|
|
def test_multiple_sentences_chinese(self):
|
|
result = _split_script_into_sentences("第一句。第二句!第三句?")
|
|
self.assertEqual(result, ["第一句", "第二句", "第三句"])
|
|
|
|
def test_english_punctuation(self):
|
|
result = _split_script_into_sentences("Hello World! How are you?")
|
|
self.assertEqual(result, ["Hello World", "How are you"])
|
|
|
|
def test_semicolons(self):
|
|
result = _split_script_into_sentences("第一部分;第二部分;第三部分")
|
|
self.assertEqual(result, ["第一部分", "第二部分", "第三部分"])
|
|
|
|
def test_newlines(self):
|
|
result = _split_script_into_sentences("第一行\n第二行\n第三行")
|
|
self.assertEqual(result, ["第一行", "第二行", "第三行"])
|
|
|
|
def test_no_trailing_punctuation(self):
|
|
result = _split_script_into_sentences("没有标点的句子")
|
|
self.assertEqual(result, ["没有标点的句子"])
|
|
|
|
def test_chinese_comma_not_split(self):
|
|
"""Issue #1892:中文逗号「,」不应分句,保持完整小句。"""
|
|
result = _split_script_into_sentences("卖花的叫花无缺,卖姜的叫姜子牙。")
|
|
self.assertEqual(result, ["卖花的叫花无缺,卖姜的叫姜子牙"])
|
|
|
|
def test_english_comma_not_split(self):
|
|
"""Issue #1892:英文逗号「,」不应分句。"""
|
|
result = _split_script_into_sentences("Hello, how are you?")
|
|
self.assertEqual(result, ["Hello, how are you"])
|
|
|
|
def test_comma_between_sentences_kept_in_sentence(self):
|
|
"""两个句号间带逗号的长句:逗号不切,只按句号切。"""
|
|
result = _split_script_into_sentences("第一句,带逗号。第二句,也带逗号!")
|
|
self.assertEqual(result, ["第一句,带逗号", "第二句,也带逗号"])
|
|
|
|
|
|
class TestEstimateSentenceTimingsByChars(unittest.TestCase):
|
|
"""Tests for _estimate_sentence_timings_by_chars."""
|
|
|
|
def test_empty_sentences(self):
|
|
self.assertEqual(_estimate_sentence_timings_by_chars([], 10.0), [])
|
|
|
|
def test_zero_duration(self):
|
|
self.assertEqual(_estimate_sentence_timings_by_chars(["hello"], 0), [])
|
|
|
|
def test_negative_duration(self):
|
|
self.assertEqual(_estimate_sentence_timings_by_chars(["hello"], -5.0), [])
|
|
|
|
def test_single_sentence(self):
|
|
result = _estimate_sentence_timings_by_chars(["hello"], 10.0)
|
|
self.assertEqual(len(result), 1)
|
|
self.assertAlmostEqual(result[0]["start_time"], 0.0)
|
|
self.assertAlmostEqual(result[0]["end_time"], 10.0)
|
|
|
|
def test_two_equal_sentences(self):
|
|
result = _estimate_sentence_timings_by_chars(["你好", "世界"], 10.0)
|
|
self.assertEqual(len(result), 2)
|
|
self.assertAlmostEqual(result[0]["start_time"], 0.0)
|
|
self.assertAlmostEqual(result[0]["end_time"], 5.0)
|
|
self.assertAlmostEqual(result[1]["start_time"], 5.0)
|
|
self.assertAlmostEqual(result[1]["end_time"], 10.0)
|
|
|
|
def test_unequal_char_distribution(self):
|
|
result = _estimate_sentence_timings_by_chars(["ABCD", "EF"], 9.0)
|
|
self.assertEqual(len(result), 2)
|
|
self.assertAlmostEqual(result[0]["start_time"], 0.0)
|
|
self.assertAlmostEqual(result[0]["end_time"], 6.0) # 4/6 * 9 = 6
|
|
self.assertAlmostEqual(result[1]["start_time"], 6.0)
|
|
self.assertAlmostEqual(result[1]["end_time"], 9.0)
|
|
|
|
def test_timing_structure(self):
|
|
result = _estimate_sentence_timings_by_chars(["句子一", "句子二"], 6.0)
|
|
for item in result:
|
|
self.assertIn("index", item)
|
|
self.assertIn("text", item)
|
|
self.assertIn("start_time", item)
|
|
self.assertIn("end_time", item)
|
|
|
|
|
|
class TestComputeSentenceTimings(unittest.TestCase):
|
|
"""Tests for _compute_sentence_timings."""
|
|
|
|
def test_empty_script_returns_empty(self):
|
|
self.assertEqual(_compute_sentence_timings(b"fake_audio", "", 10.0), [])
|
|
|
|
def test_none_script_returns_empty(self):
|
|
self.assertEqual(_compute_sentence_timings(b"fake_audio", None, 10.0), [])
|
|
|
|
@patch("os.unlink")
|
|
@patch.object(tempfile, "NamedTemporaryFile")
|
|
@patch.object(subprocess, "run")
|
|
def test_silence_detection_insufficient_fallback(self, mock_run, mock_tmpfile, mock_unlink):
|
|
"""When silence detection finds too few points, fallback to char estimation."""
|
|
mock_run.return_value = MagicMock(stderr="", returncode=0)
|
|
mock_tmp = MagicMock()
|
|
mock_tmp.name = "/tmp/fake.mp3"
|
|
mock_tmp.__enter__ = MagicMock(return_value=mock_tmp)
|
|
mock_tmp.__exit__ = MagicMock(return_value=False)
|
|
mock_tmpfile.return_value = mock_tmp
|
|
|
|
result = _compute_sentence_timings(b"fake_audio", "第一句。第二句。第三句。", 10.0)
|
|
|
|
# Should fallback to char estimation with 3 sentences
|
|
self.assertEqual(len(result), 3)
|
|
self.assertAlmostEqual(result[0]["start_time"], 0.0)
|
|
|
|
@patch("os.unlink")
|
|
@patch.object(tempfile, "NamedTemporaryFile")
|
|
@patch.object(subprocess, "run")
|
|
def test_silence_detection_with_enough_points(self, mock_run, mock_tmpfile, mock_unlink):
|
|
"""When silence detection finds enough points, use them for boundaries."""
|
|
mock_run.return_value = MagicMock(
|
|
stderr="[silencedetect] silence_end: 3.5 | silence_duration: 0.4\n"
|
|
"[silencedetect] silence_end: 7.0 | silence_duration: 0.3\n",
|
|
returncode=0,
|
|
)
|
|
mock_tmp = MagicMock()
|
|
mock_tmp.name = "/tmp/fake.mp3"
|
|
mock_tmp.__enter__ = MagicMock(return_value=mock_tmp)
|
|
mock_tmp.__exit__ = MagicMock(return_value=False)
|
|
mock_tmpfile.return_value = mock_tmp
|
|
|
|
result = _compute_sentence_timings(b"fake_audio", "第一句。第二句。第三句。", 10.0)
|
|
|
|
self.assertEqual(len(result), 3)
|
|
self.assertAlmostEqual(result[0]["start_time"], 0.0)
|
|
self.assertAlmostEqual(result[0]["end_time"], 3.5)
|
|
self.assertAlmostEqual(result[1]["start_time"], 3.5)
|
|
self.assertAlmostEqual(result[1]["end_time"], 7.0)
|
|
self.assertAlmostEqual(result[2]["start_time"], 7.0)
|
|
self.assertAlmostEqual(result[2]["end_time"], 10.0)
|
|
|
|
@patch("os.unlink")
|
|
@patch.object(tempfile, "NamedTemporaryFile")
|
|
@patch.object(subprocess, "run")
|
|
def test_ffmpeg_exception_fallback(self, mock_run, mock_tmpfile, mock_unlink):
|
|
"""When ffmpeg raises an exception, fallback to char estimation."""
|
|
mock_run.side_effect = Exception("ffmpeg not found")
|
|
mock_tmp = MagicMock()
|
|
mock_tmp.name = "/tmp/fake.mp3"
|
|
mock_tmp.__enter__ = MagicMock(return_value=mock_tmp)
|
|
mock_tmp.__exit__ = MagicMock(return_value=False)
|
|
mock_tmpfile.return_value = mock_tmp
|
|
|
|
result = _compute_sentence_timings(b"fake_audio", "句子一。句子二。", 6.0)
|
|
|
|
# Should fallback to char estimation
|
|
self.assertEqual(len(result), 2)
|
|
self.assertAlmostEqual(result[0]["start_time"], 0.0)
|
|
self.assertAlmostEqual(result[0]["end_time"], 3.0)
|
|
self.assertAlmostEqual(result[1]["start_time"], 3.0)
|
|
self.assertAlmostEqual(result[1]["end_time"], 6.0)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|