feat(tree): 字幕模块 — SRT 解析 + 完整性检查 + Voronoi 分配
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,118 @@
|
||||
"""字幕模块单元测试。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from app.tree.subtitle import (
|
||||
SRTEntry,
|
||||
assign_subtitles_voronoi,
|
||||
check_subtitle_completeness,
|
||||
extract_subtitle_for_range,
|
||||
parse_srt,
|
||||
)
|
||||
|
||||
_SAMPLE_SRT = """\
|
||||
1
|
||||
00:00:01,000 --> 00:00:03,500
|
||||
Hello world.
|
||||
|
||||
2
|
||||
00:00:05,000 --> 00:00:08,000
|
||||
<i>This is italic</i> text.
|
||||
|
||||
3
|
||||
00:00:10,000 --> 00:00:12,000
|
||||
Final line.
|
||||
"""
|
||||
|
||||
|
||||
class TestParseSrt:
|
||||
def test_basic_parse(self, tmp_path):
|
||||
srt_file = tmp_path / "test.srt"
|
||||
srt_file.write_text(_SAMPLE_SRT, encoding="utf-8")
|
||||
entries = parse_srt(str(srt_file))
|
||||
assert len(entries) == 3
|
||||
assert entries[0] == SRTEntry(start=1.0, end=3.5, text="Hello world.")
|
||||
assert entries[1].text == "This is italic text."
|
||||
|
||||
def test_empty_srt(self, tmp_path):
|
||||
srt_file = tmp_path / "empty.srt"
|
||||
srt_file.write_text("", encoding="utf-8")
|
||||
entries = parse_srt(str(srt_file))
|
||||
assert entries == []
|
||||
|
||||
def test_malformed_srt_skips_bad_blocks(self, tmp_path):
|
||||
bad_srt = "garbage\n\n1\n00:00:01,000 --> 00:00:02,000\nGood line.\n"
|
||||
srt_file = tmp_path / "bad.srt"
|
||||
srt_file.write_text(bad_srt, encoding="utf-8")
|
||||
entries = parse_srt(str(srt_file))
|
||||
assert len(entries) == 1
|
||||
assert entries[0].text == "Good line."
|
||||
|
||||
|
||||
class TestCompletenessCheck:
|
||||
def test_good_coverage(self):
|
||||
entries = [SRTEntry(0.0, 5.0, "a"), SRTEntry(5.0, 10.0, "b")]
|
||||
report = check_subtitle_completeness(entries, duration=10.0, min_coverage=0.5)
|
||||
assert report.usable is True
|
||||
assert report.coverage_ratio >= 0.5
|
||||
|
||||
def test_poor_coverage(self):
|
||||
entries = [SRTEntry(0.0, 1.0, "short")]
|
||||
report = check_subtitle_completeness(entries, duration=100.0, min_coverage=0.3)
|
||||
assert report.usable is False
|
||||
|
||||
def test_max_gap(self):
|
||||
entries = [SRTEntry(0.0, 1.0, "a"), SRTEntry(50.0, 51.0, "b")]
|
||||
report = check_subtitle_completeness(entries, duration=60.0)
|
||||
assert report.max_gap_sec >= 49.0
|
||||
|
||||
|
||||
class TestExtractForRange:
|
||||
def test_overlap(self):
|
||||
entries = [
|
||||
SRTEntry(0.0, 5.0, "first"),
|
||||
SRTEntry(4.0, 8.0, "second"),
|
||||
SRTEntry(10.0, 12.0, "third"),
|
||||
]
|
||||
text = extract_subtitle_for_range(entries, (3.0, 9.0))
|
||||
assert "first" in text
|
||||
assert "second" in text
|
||||
assert "third" not in text
|
||||
|
||||
|
||||
class TestVoronoiAssign:
|
||||
def test_assigns_to_l3_nodes(self):
|
||||
from app.tree.index import (
|
||||
IndexMeta,
|
||||
L1Card,
|
||||
L1Node,
|
||||
L2Card,
|
||||
L2Node,
|
||||
L3Card,
|
||||
L3Node,
|
||||
TreeIndex,
|
||||
)
|
||||
|
||||
l3_0 = L3Node(id="l1_0_l2_0_l3_0", card=L3Card("desc0", [], [], [], "", {}), timestamp=2.0)
|
||||
l3_1 = L3Node(id="l1_0_l2_0_l3_1", card=L3Card("desc1", [], [], [], "", {}), timestamp=6.0)
|
||||
l2 = L2Node(
|
||||
id="l1_0_l2_0",
|
||||
card=L2Card("evt", [], [], [], [], "", None),
|
||||
time_range=(0.0, 10.0),
|
||||
children=[l3_0, l3_1],
|
||||
)
|
||||
l1 = L1Node(
|
||||
id="l1_0",
|
||||
card=L1Card("scene", "", [], [], [], [], ""),
|
||||
time_range=(0.0, 10.0),
|
||||
children=[l2],
|
||||
)
|
||||
index = TreeIndex(metadata=IndexMeta("/test.mp4", "video"), roots=[l1])
|
||||
|
||||
entries = [SRTEntry(1.0, 3.0, "hello"), SRTEntry(5.0, 7.0, "world")]
|
||||
assign_subtitles_voronoi(index, entries)
|
||||
|
||||
assert l3_0.subtitle is not None
|
||||
assert "hello" in l3_0.subtitle
|
||||
assert l3_1.subtitle is not None
|
||||
assert "world" in l3_1.subtitle
|
||||
Reference in New Issue
Block a user