Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9603ce2b7e | |||
| 57c59cb112 |
Executable
+497
@@ -0,0 +1,497 @@
|
||||
"""subtitle 字幕时间轴领域模型单测."""
|
||||
|
||||
import pytest
|
||||
from domain.subtitle import SubtitleSegment, SubtitleTimeline, SubtitleWord
|
||||
|
||||
# ── SubtitleWord ─────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestSubtitleWord:
|
||||
"""SubtitleWord 词级字幕单元"""
|
||||
|
||||
def test_basic(self):
|
||||
w = SubtitleWord(text="你好", start=1.0, end=1.5)
|
||||
assert w.text == "你好"
|
||||
assert w.start == 1.0
|
||||
assert w.end == 1.5
|
||||
|
||||
def test_duration(self):
|
||||
w = SubtitleWord(text="test", start=0.0, end=2.5)
|
||||
assert w.duration == 2.5
|
||||
|
||||
def test_duration_zero(self):
|
||||
w = SubtitleWord(text="x", start=5.0, end=5.0)
|
||||
assert w.duration == 0.0
|
||||
|
||||
def test_duration_negative_becomes_zero(self):
|
||||
w = SubtitleWord(text="x", start=3.0, end=2.0)
|
||||
assert w.duration == 0.0
|
||||
|
||||
|
||||
# ── SubtitleSegment ──────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestSubtitleSegment:
|
||||
"""SubtitleSegment 字幕片段"""
|
||||
|
||||
def test_basic(self):
|
||||
s = SubtitleSegment(text="你好世界", start=0.0, end=2.0)
|
||||
assert s.text == "你好世界"
|
||||
assert s.start == 0.0
|
||||
assert s.end == 2.0
|
||||
assert s.words == []
|
||||
|
||||
def test_with_words(self):
|
||||
words = [
|
||||
SubtitleWord("你好", 0.0, 0.5),
|
||||
SubtitleWord("世界", 0.5, 1.0),
|
||||
]
|
||||
s = SubtitleSegment(text="你好世界", start=0.0, end=1.0, words=words)
|
||||
assert len(s.words) == 2
|
||||
assert s.words[0].text == "你好"
|
||||
|
||||
def test_duration(self):
|
||||
s = SubtitleSegment(text="test", start=1.5, end=3.5)
|
||||
assert s.duration == 2.0
|
||||
|
||||
def test_duration_negative_becomes_zero(self):
|
||||
s = SubtitleSegment(text="test", start=5.0, end=3.0)
|
||||
assert s.duration == 0.0
|
||||
|
||||
def test_char_count(self):
|
||||
s = SubtitleSegment(text="你好世界", start=0, end=1)
|
||||
assert s.char_count == 4
|
||||
|
||||
def test_char_count_empty(self):
|
||||
s = SubtitleSegment(text="", start=0, end=1)
|
||||
assert s.char_count == 0
|
||||
|
||||
def test_char_count_mixed(self):
|
||||
s = SubtitleSegment(text="Hello 世界", start=0, end=1)
|
||||
assert s.char_count == 8 # H-e-l-l-o- -世-界
|
||||
|
||||
|
||||
# ── SubtitleTimeline 基础 ────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestSubtitleTimelineBasics:
|
||||
"""SubtitleTimeline 基础属性"""
|
||||
|
||||
def test_defaults(self):
|
||||
tl = SubtitleTimeline()
|
||||
assert tl.segments == []
|
||||
assert tl.language == "zh"
|
||||
assert tl.total_duration == 0.0
|
||||
|
||||
def test_custom_language(self):
|
||||
tl = SubtitleTimeline(language="en")
|
||||
assert tl.language == "en"
|
||||
|
||||
def test_segment_count(self):
|
||||
tl = SubtitleTimeline(
|
||||
segments=[
|
||||
SubtitleSegment("a", 0, 1),
|
||||
SubtitleSegment("b", 1, 2),
|
||||
]
|
||||
)
|
||||
assert tl.segment_count == 2
|
||||
|
||||
def test_segment_count_empty(self):
|
||||
tl = SubtitleTimeline()
|
||||
assert tl.segment_count == 0
|
||||
|
||||
def test_total_chars(self):
|
||||
tl = SubtitleTimeline(
|
||||
segments=[
|
||||
SubtitleSegment("你好", 0, 1),
|
||||
SubtitleSegment("世界", 1, 2),
|
||||
]
|
||||
)
|
||||
assert tl.total_chars == 4
|
||||
|
||||
def test_total_chars_empty(self):
|
||||
tl = SubtitleTimeline()
|
||||
assert tl.total_chars == 0
|
||||
|
||||
|
||||
# ── merge_short_segments ─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestMergeShortSegments:
|
||||
"""merge_short_segments 合并过短片段"""
|
||||
|
||||
def test_empty_timeline(self):
|
||||
tl = SubtitleTimeline()
|
||||
result = tl.merge_short_segments()
|
||||
assert result.segment_count == 0
|
||||
|
||||
def test_single_segment(self):
|
||||
tl = SubtitleTimeline(
|
||||
segments=[
|
||||
SubtitleSegment("短", 0, 1),
|
||||
]
|
||||
)
|
||||
result = tl.merge_short_segments(min_chars=8)
|
||||
assert result.segment_count == 1
|
||||
assert result.segments[0].text == "短"
|
||||
|
||||
def test_two_short_segments_merged(self):
|
||||
tl = SubtitleTimeline(
|
||||
segments=[
|
||||
SubtitleSegment("你好", 0, 1), # 2
|
||||
SubtitleSegment("世界", 1, 2), # 2
|
||||
]
|
||||
)
|
||||
result = tl.merge_short_segments(min_chars=3)
|
||||
assert result.segment_count == 1
|
||||
assert result.segments[0].text == "你好世界"
|
||||
assert result.segments[0].start == 0.0
|
||||
assert result.segments[0].end == 2.0
|
||||
|
||||
def test_multiple_short_merged(self):
|
||||
tl = SubtitleTimeline(
|
||||
segments=[
|
||||
SubtitleSegment("一", 0, 0.5), # 1
|
||||
SubtitleSegment("二", 0.5, 1.0), # 1
|
||||
SubtitleSegment("三", 1.0, 1.5), # 1
|
||||
SubtitleSegment("四", 1.5, 2.0), # 1
|
||||
SubtitleSegment("五", 2.0, 2.5), # 1
|
||||
SubtitleSegment("六七八", 2.5, 3.5), # 3
|
||||
SubtitleSegment("八九十", 3.5, 4.5), # 3
|
||||
]
|
||||
)
|
||||
result = tl.merge_short_segments(min_chars=5)
|
||||
# 一二三四五 5个=5 → 合并为1段
|
||||
# 六七八+八九十 3+3=6 → 合并为1段
|
||||
assert result.segment_count == 2
|
||||
assert result.segments[0].text == "一二三四五"
|
||||
assert result.segments[1].text == "六七八八九十"
|
||||
|
||||
def test_long_segment_stays_alone(self):
|
||||
tl = SubtitleTimeline(
|
||||
segments=[
|
||||
SubtitleSegment("这是一段很长的字幕内容", 0, 2), # 11
|
||||
SubtitleSegment("短", 2, 2.5), # 1
|
||||
SubtitleSegment("语", 2.5, 3.0), # 1
|
||||
]
|
||||
)
|
||||
result = tl.merge_short_segments(min_chars=8)
|
||||
# 第一段11字>=8,单独输出;后两段加起来2字<8,合并到上一段
|
||||
assert result.segment_count == 1
|
||||
assert result.segments[0].text == "这是一段很长的字幕内容短语"
|
||||
|
||||
def test_tail_short_merged_with_previous(self):
|
||||
tl = SubtitleTimeline(
|
||||
segments=[
|
||||
SubtitleSegment("一二三四五六七八", 0, 2), # 8
|
||||
SubtitleSegment("尾", 2, 2.5), # 1,太短了
|
||||
]
|
||||
)
|
||||
result = tl.merge_short_segments(min_chars=5)
|
||||
assert result.segment_count == 1
|
||||
assert result.segments[0].text == "一二三四五六七八尾"
|
||||
|
||||
def test_preserves_language_and_duration(self):
|
||||
tl = SubtitleTimeline(
|
||||
segments=[SubtitleSegment("a", 0, 1)],
|
||||
language="en",
|
||||
total_duration=10.0,
|
||||
)
|
||||
result = tl.merge_short_segments()
|
||||
assert result.language == "en"
|
||||
assert result.total_duration == 10.0
|
||||
|
||||
def test_default_min_chars_is_8(self):
|
||||
tl = SubtitleTimeline(
|
||||
segments=[
|
||||
SubtitleSegment("一二三四五", 0, 1), # 5 < 8
|
||||
SubtitleSegment("六七八", 1, 2), # 3 → 5+3=8
|
||||
]
|
||||
)
|
||||
result = tl.merge_short_segments()
|
||||
assert result.segment_count == 1
|
||||
|
||||
def test_merges_words(self):
|
||||
words1 = [SubtitleWord("你", 0.0, 0.3), SubtitleWord("好", 0.3, 0.6)]
|
||||
words2 = [SubtitleWord("世", 1.0, 1.3), SubtitleWord("界", 1.3, 1.6)]
|
||||
tl = SubtitleTimeline(
|
||||
segments=[
|
||||
SubtitleSegment("你好", 0.0, 0.6, words=words1),
|
||||
SubtitleSegment("世界", 1.0, 1.6, words=words2),
|
||||
]
|
||||
)
|
||||
result = tl.merge_short_segments(min_chars=3)
|
||||
assert result.segment_count == 1
|
||||
assert len(result.segments[0].words) == 4
|
||||
assert result.segments[0].words[0].text == "你"
|
||||
assert result.segments[0].words[3].text == "界"
|
||||
|
||||
def test_does_not_mutate_original(self):
|
||||
tl = SubtitleTimeline(
|
||||
segments=[
|
||||
SubtitleSegment("a", 0, 1),
|
||||
SubtitleSegment("b", 1, 2),
|
||||
]
|
||||
)
|
||||
original_count = tl.segment_count
|
||||
tl.merge_short_segments(min_chars=5)
|
||||
assert tl.segment_count == original_count
|
||||
|
||||
|
||||
# ── split_long_segments ──────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestSplitLongSegments:
|
||||
"""split_long_segments 拆分过长片段"""
|
||||
|
||||
def test_short_segment_no_split(self):
|
||||
tl = SubtitleTimeline(
|
||||
segments=[
|
||||
SubtitleSegment("短文本", 0, 1),
|
||||
]
|
||||
)
|
||||
result = tl.split_long_segments(max_chars=20)
|
||||
assert result.segment_count == 1
|
||||
assert result.segments[0].text == "短文本"
|
||||
|
||||
def test_empty_timeline(self):
|
||||
tl = SubtitleTimeline()
|
||||
result = tl.split_long_segments()
|
||||
assert result.segment_count == 0
|
||||
|
||||
def test_split_by_sentence_end(self):
|
||||
tl = SubtitleTimeline(
|
||||
segments=[
|
||||
SubtitleSegment(
|
||||
"这是第一句话。这是第二句话。这是第三句话。",
|
||||
start=0.0,
|
||||
end=9.0,
|
||||
),
|
||||
]
|
||||
)
|
||||
result = tl.split_long_segments(max_chars=10)
|
||||
assert result.segment_count >= 2
|
||||
# 第一句应该是完整的
|
||||
assert result.segments[0].text.endswith("。")
|
||||
|
||||
def test_split_preserves_total_text(self):
|
||||
original = "这是第一句话。这是第二句话。这是第三句话,很长的一句话。"
|
||||
tl = SubtitleTimeline(
|
||||
segments=[
|
||||
SubtitleSegment(original, start=0.0, end=10.0),
|
||||
]
|
||||
)
|
||||
result = tl.split_long_segments(max_chars=8)
|
||||
# 拆分后所有片段拼起来应该等于原文
|
||||
combined = "".join(s.text for s in result.segments)
|
||||
assert combined == original
|
||||
|
||||
def test_split_time_proportional(self):
|
||||
text = "一二三四五六七八九十。一二三四五六七八九十。"
|
||||
tl = SubtitleTimeline(
|
||||
segments=[
|
||||
SubtitleSegment(text=text, start=0.0, end=10.0),
|
||||
]
|
||||
)
|
||||
result = tl.split_long_segments(max_chars=12)
|
||||
assert result.segment_count >= 2
|
||||
# 第一段结束时间应该早于总时长
|
||||
assert result.segments[0].end < 10.0
|
||||
# 最后一段结束应该等于原结束时间
|
||||
assert abs(result.segments[-1].end - 10.0) < 0.01
|
||||
|
||||
def test_no_punctuation_hard_split(self):
|
||||
text = "一二三四五六七八九十一二三四五六七八九十一二三四五"
|
||||
tl = SubtitleTimeline(
|
||||
segments=[
|
||||
SubtitleSegment(text=text, start=0.0, end=10.0),
|
||||
]
|
||||
)
|
||||
result = tl.split_long_segments(max_chars=10)
|
||||
assert result.segment_count >= 3
|
||||
combined = "".join(s.text for s in result.segments)
|
||||
assert combined == text
|
||||
|
||||
def test_multiple_mixed_segments(self):
|
||||
tl = SubtitleTimeline(
|
||||
segments=[
|
||||
SubtitleSegment("短", 0, 1),
|
||||
SubtitleSegment("这是一段非常非常长的字幕文本内容需要拆分", 1, 5),
|
||||
SubtitleSegment("短的", 5, 6),
|
||||
]
|
||||
)
|
||||
result = tl.split_long_segments(max_chars=10)
|
||||
# 第一个和第三个保持不变,中间被拆分
|
||||
assert result.segment_count > 3
|
||||
assert result.segments[0].text == "短"
|
||||
assert result.segments[-1].text == "短的"
|
||||
|
||||
def test_preserves_language_and_total_duration(self):
|
||||
tl = SubtitleTimeline(
|
||||
segments=[SubtitleSegment("a" * 30, 0, 10)],
|
||||
language="ja",
|
||||
total_duration=20.0,
|
||||
)
|
||||
result = tl.split_long_segments(max_chars=10)
|
||||
assert result.language == "ja"
|
||||
assert result.total_duration == 20.0
|
||||
|
||||
def test_default_max_chars_is_20(self):
|
||||
text = "一" * 25
|
||||
tl = SubtitleTimeline(
|
||||
segments=[
|
||||
SubtitleSegment(text=text, start=0, end=5),
|
||||
]
|
||||
)
|
||||
result = tl.split_long_segments()
|
||||
assert result.segment_count >= 2
|
||||
|
||||
def test_split_with_words(self):
|
||||
words = [SubtitleWord(f"w{i}", i * 0.5, i * 0.5 + 0.4) for i in range(20)]
|
||||
text = "".join(w.text for w in words)
|
||||
tl = SubtitleTimeline(
|
||||
segments=[
|
||||
SubtitleSegment(text=text, start=0.0, end=10.0, words=words),
|
||||
]
|
||||
)
|
||||
result = tl.split_long_segments(max_chars=10)
|
||||
assert result.segment_count >= 2
|
||||
# 所有片段的词数之和应该等于原词数
|
||||
total_words = sum(len(s.words) for s in result.segments)
|
||||
assert total_words <= len(words) + 1 # 可能有边界误差
|
||||
|
||||
def test_does_not_mutate_original(self):
|
||||
tl = SubtitleTimeline(
|
||||
segments=[
|
||||
SubtitleSegment("a" * 30, 0, 10),
|
||||
]
|
||||
)
|
||||
original_count = tl.segment_count
|
||||
tl.split_long_segments(max_chars=10)
|
||||
assert tl.segment_count == original_count
|
||||
|
||||
|
||||
# ── _split_text_by_punctuation 静态方法 ─────────────────────────────────────
|
||||
|
||||
|
||||
class TestSplitTextByPunctuation:
|
||||
"""_split_text_by_punctuation 静态方法"""
|
||||
|
||||
def test_short_text_no_split(self):
|
||||
result = SubtitleTimeline._split_text_by_punctuation("短文本", max_chars=20)
|
||||
assert len(result) == 1
|
||||
assert result[0] == "短文本"
|
||||
|
||||
def test_sentence_end_punctuation_split(self):
|
||||
result = SubtitleTimeline._split_text_by_punctuation(
|
||||
"第一句。第二句。第三句。",
|
||||
max_chars=5,
|
||||
)
|
||||
assert len(result) >= 2
|
||||
assert result[0] == "第一句。"
|
||||
|
||||
def test_clause_pause_punctuation(self):
|
||||
result = SubtitleTimeline._split_text_by_punctuation(
|
||||
"今天天气很好,阳光明媚,适合出去玩。",
|
||||
max_chars=8,
|
||||
)
|
||||
assert len(result) >= 2
|
||||
|
||||
def test_exclamation_mark(self):
|
||||
result = SubtitleTimeline._split_text_by_punctuation(
|
||||
"太精彩了!真的很棒!",
|
||||
max_chars=5,
|
||||
)
|
||||
assert len(result) >= 2
|
||||
|
||||
def test_question_mark(self):
|
||||
result = SubtitleTimeline._split_text_by_punctuation(
|
||||
"你是谁?从哪里来?",
|
||||
max_chars=5,
|
||||
)
|
||||
assert len(result) >= 2
|
||||
|
||||
def test_english_punctuation(self):
|
||||
result = SubtitleTimeline._split_text_by_punctuation(
|
||||
"Hello, world! How are you?",
|
||||
max_chars=10,
|
||||
)
|
||||
assert len(result) >= 2
|
||||
|
||||
def test_no_punctuation_hard_split(self):
|
||||
text = "一" * 25
|
||||
result = SubtitleTimeline._split_text_by_punctuation(text, max_chars=10)
|
||||
assert len(result) >= 3
|
||||
assert "".join(result) == text
|
||||
|
||||
def test_empty_string(self):
|
||||
result = SubtitleTimeline._split_text_by_punctuation("", max_chars=10)
|
||||
assert len(result) == 0 or (len(result) == 1 and result[0] == "")
|
||||
|
||||
def test_semicolon_colon(self):
|
||||
result = SubtitleTimeline._split_text_by_punctuation(
|
||||
"注意事项:第一,要认真;第二,要仔细。",
|
||||
max_chars=8,
|
||||
)
|
||||
assert len(result) >= 2
|
||||
|
||||
|
||||
# ── _merge_segments 静态方法 ────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestMergeSegmentsStatic:
|
||||
"""_merge_segments 静态方法"""
|
||||
|
||||
def test_empty_list(self):
|
||||
result = SubtitleTimeline._merge_segments([])
|
||||
assert result.text == ""
|
||||
assert result.start == 0
|
||||
assert result.end == 0
|
||||
|
||||
def test_single_segment(self):
|
||||
seg = SubtitleSegment("hello", 1.0, 2.0)
|
||||
result = SubtitleTimeline._merge_segments([seg])
|
||||
assert result.text == "hello"
|
||||
assert result.start == 1.0
|
||||
assert result.end == 2.0
|
||||
|
||||
def test_two_segments(self):
|
||||
s1 = SubtitleSegment("你好", 0.0, 1.0)
|
||||
s2 = SubtitleSegment("世界", 1.0, 2.0)
|
||||
result = SubtitleTimeline._merge_segments([s1, s2])
|
||||
assert result.text == "你好世界"
|
||||
assert result.start == 0.0
|
||||
assert result.end == 2.0
|
||||
|
||||
def test_merges_words(self):
|
||||
w1 = [SubtitleWord("你", 0, 0.5)]
|
||||
w2 = [SubtitleWord("好", 0.5, 1.0)]
|
||||
s1 = SubtitleSegment("你", 0, 0.5, words=w1)
|
||||
s2 = SubtitleSegment("好", 0.5, 1.0, words=w2)
|
||||
result = SubtitleTimeline._merge_segments([s1, s2])
|
||||
assert len(result.words) == 2
|
||||
assert result.words[0].text == "你"
|
||||
assert result.words[1].text == "好"
|
||||
|
||||
|
||||
# ── 端到端:先合并再拆分 ────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestMergeAndSplit:
|
||||
"""合并和拆分组合使用"""
|
||||
|
||||
def test_merge_then_split_roundtrip(self):
|
||||
# 很多短句先合并,再按合理长度拆分
|
||||
segments = [
|
||||
SubtitleSegment("你好", 0, 0.5),
|
||||
SubtitleSegment("我是小明", 0.5, 1.5),
|
||||
SubtitleSegment("今天天气真好。", 1.5, 3.0),
|
||||
SubtitleSegment("我们出去玩吧。", 3.0, 5.0),
|
||||
]
|
||||
tl = SubtitleTimeline(segments=segments)
|
||||
merged = tl.merge_short_segments(min_chars=5)
|
||||
split = merged.split_long_segments(max_chars=15)
|
||||
# 结果应该合理(不保证完全一样,但文本应该完整)
|
||||
original_text = "".join(s.text for s in segments)
|
||||
result_text = "".join(s.text for s in split.segments)
|
||||
assert original_text == result_text
|
||||
@@ -1,280 +0,0 @@
|
||||
"""VerificationCode 单元测试."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from domain.verification_code import VerificationCode
|
||||
|
||||
|
||||
class TestVerificationCodeCreate:
|
||||
"""create() 工厂方法测试."""
|
||||
|
||||
def test_create_basic(self):
|
||||
vc = VerificationCode.create("test@example.com", "email_login")
|
||||
assert vc.id is not None
|
||||
assert len(vc.id) == 32
|
||||
assert vc.recipient == "test@example.com"
|
||||
assert vc.code_type == "email_login"
|
||||
assert len(vc.code) == 6
|
||||
assert vc.code.isdigit()
|
||||
assert vc.used_at is None
|
||||
assert vc.attempts == 0
|
||||
assert vc.created_at is not None
|
||||
assert vc.expires_at > vc.created_at
|
||||
|
||||
def test_create_recipient_stripped(self):
|
||||
vc = VerificationCode.create(" test@example.com ", "email_login")
|
||||
assert vc.recipient == "test@example.com"
|
||||
|
||||
def test_create_custom_code(self):
|
||||
vc = VerificationCode.create("test@example.com", "email_login", custom_code="123456")
|
||||
assert vc.code == "123456"
|
||||
|
||||
def test_create_custom_ttl(self):
|
||||
fixed_now = datetime(2026, 1, 1, 12, 0, 0, tzinfo=timezone.utc)
|
||||
with patch("domain.verification_code.datetime") as mock_dt:
|
||||
mock_dt.now.return_value = fixed_now
|
||||
mock_dt.side_effect = lambda *a, **kw: datetime(*a, **kw)
|
||||
vc = VerificationCode.create("test@example.com", "email_login", ttl_seconds=60)
|
||||
assert vc.expires_at == fixed_now + timedelta(seconds=60)
|
||||
|
||||
def test_create_default_ttl_300(self):
|
||||
fixed_now = datetime(2026, 1, 1, 12, 0, 0, tzinfo=timezone.utc)
|
||||
with patch("domain.verification_code.datetime") as mock_dt:
|
||||
mock_dt.now.return_value = fixed_now
|
||||
mock_dt.side_effect = lambda *a, **kw: datetime(*a, **kw)
|
||||
vc = VerificationCode.create("test@example.com", "email_login")
|
||||
assert vc.expires_at == fixed_now + timedelta(seconds=300)
|
||||
|
||||
def test_create_unique_ids(self):
|
||||
vc1 = VerificationCode.create("a@b.com", "email_login")
|
||||
vc2 = VerificationCode.create("a@b.com", "email_login")
|
||||
assert vc1.id != vc2.id
|
||||
|
||||
def test_create_unique_codes(self):
|
||||
codes = set()
|
||||
for _ in range(20):
|
||||
vc = VerificationCode.create("a@b.com", "email_login")
|
||||
codes.add(vc.code)
|
||||
# 20个随机6位码几乎肯定不都一样
|
||||
assert len(codes) > 1
|
||||
|
||||
def test_create_phone_recipient(self):
|
||||
vc = VerificationCode.create("13800138000", "phone_login")
|
||||
assert vc.recipient == "13800138000"
|
||||
assert vc.code_type == "phone_login"
|
||||
|
||||
def test_create_all_code_types(self):
|
||||
for ct in ["email_bind", "phone_bind", "email_login", "phone_login", "reset_password"]:
|
||||
vc = VerificationCode.create("test@example.com", ct)
|
||||
assert vc.code_type == ct
|
||||
|
||||
|
||||
class TestVerificationCodeIsExpired:
|
||||
"""is_expired 属性测试."""
|
||||
|
||||
def test_not_expired_future(self):
|
||||
future = datetime.now(timezone.utc) + timedelta(hours=1)
|
||||
vc = VerificationCode(
|
||||
id="1",
|
||||
recipient="a@b.com",
|
||||
code="123456",
|
||||
code_type="email_login",
|
||||
expires_at=future,
|
||||
)
|
||||
assert vc.is_expired is False
|
||||
|
||||
def test_expired_past(self):
|
||||
past = datetime.now(timezone.utc) - timedelta(hours=1)
|
||||
vc = VerificationCode(
|
||||
id="1",
|
||||
recipient="a@b.com",
|
||||
code="123456",
|
||||
code_type="email_login",
|
||||
expires_at=past,
|
||||
)
|
||||
assert vc.is_expired is True
|
||||
|
||||
def test_expired_boundary_exact(self):
|
||||
# 用mock固定时间,expires_at等于当前时间不算过期
|
||||
fixed_now = datetime(2026, 1, 1, 12, 0, 0, tzinfo=timezone.utc)
|
||||
with patch("domain.verification_code.datetime") as mock_dt:
|
||||
mock_dt.now.return_value = fixed_now
|
||||
mock_dt.side_effect = lambda *a, **kw: datetime(*a, **kw)
|
||||
vc = VerificationCode(
|
||||
id="1",
|
||||
recipient="a@b.com",
|
||||
code="123456",
|
||||
code_type="email_login",
|
||||
expires_at=fixed_now,
|
||||
)
|
||||
assert vc.is_expired is False
|
||||
|
||||
|
||||
class TestVerificationCodeIsUsed:
|
||||
"""is_used 属性测试."""
|
||||
|
||||
def test_not_used_default(self):
|
||||
vc = VerificationCode.create("a@b.com", "email_login")
|
||||
assert vc.is_used is False
|
||||
|
||||
def test_is_used_after_mark(self):
|
||||
vc = VerificationCode.create("a@b.com", "email_login")
|
||||
vc.mark_used()
|
||||
assert vc.is_used is True
|
||||
|
||||
|
||||
class TestVerificationCodeIsValid:
|
||||
"""is_valid 属性测试."""
|
||||
|
||||
def test_valid_fresh(self):
|
||||
future = datetime.now(timezone.utc) + timedelta(hours=1)
|
||||
vc = VerificationCode(
|
||||
id="1",
|
||||
recipient="a@b.com",
|
||||
code="123456",
|
||||
code_type="email_login",
|
||||
expires_at=future,
|
||||
)
|
||||
assert vc.is_valid is True
|
||||
|
||||
def test_invalid_expired(self):
|
||||
past = datetime.now(timezone.utc) - timedelta(hours=1)
|
||||
vc = VerificationCode(
|
||||
id="1",
|
||||
recipient="a@b.com",
|
||||
code="123456",
|
||||
code_type="email_login",
|
||||
expires_at=past,
|
||||
)
|
||||
assert vc.is_valid is False
|
||||
|
||||
def test_invalid_used(self):
|
||||
future = datetime.now(timezone.utc) + timedelta(hours=1)
|
||||
vc = VerificationCode(
|
||||
id="1",
|
||||
recipient="a@b.com",
|
||||
code="123456",
|
||||
code_type="email_login",
|
||||
expires_at=future,
|
||||
)
|
||||
vc.mark_used()
|
||||
assert vc.is_valid is False
|
||||
|
||||
def test_invalid_expired_and_used(self):
|
||||
past = datetime.now(timezone.utc) - timedelta(hours=1)
|
||||
vc = VerificationCode(
|
||||
id="1",
|
||||
recipient="a@b.com",
|
||||
code="123456",
|
||||
code_type="email_login",
|
||||
expires_at=past,
|
||||
)
|
||||
vc.mark_used()
|
||||
assert vc.is_valid is False
|
||||
|
||||
|
||||
class TestVerificationCodeMarkUsed:
|
||||
"""mark_used 方法测试."""
|
||||
|
||||
def test_mark_used_sets_timestamp(self):
|
||||
vc = VerificationCode.create("a@b.com", "email_login")
|
||||
assert vc.used_at is None
|
||||
before = datetime.now(timezone.utc)
|
||||
vc.mark_used()
|
||||
after = datetime.now(timezone.utc)
|
||||
assert vc.used_at is not None
|
||||
assert before <= vc.used_at <= after
|
||||
|
||||
def test_mark_used_twice_overwrites(self):
|
||||
vc = VerificationCode.create("a@b.com", "email_login")
|
||||
vc.mark_used()
|
||||
first = vc.used_at
|
||||
# 时间足够短,一般不会不同,但确保可以重复调用
|
||||
vc.mark_used()
|
||||
assert vc.used_at is not None
|
||||
|
||||
|
||||
class TestVerificationCodeIncrementAttempts:
|
||||
"""increment_attempts 方法测试."""
|
||||
|
||||
def test_default_zero(self):
|
||||
vc = VerificationCode.create("a@b.com", "email_login")
|
||||
assert vc.attempts == 0
|
||||
|
||||
def test_increment_once(self):
|
||||
vc = VerificationCode.create("a@b.com", "email_login")
|
||||
vc.increment_attempts()
|
||||
assert vc.attempts == 1
|
||||
|
||||
def test_increment_multiple(self):
|
||||
vc = VerificationCode.create("a@b.com", "email_login")
|
||||
for _i in range(5):
|
||||
vc.increment_attempts()
|
||||
assert vc.attempts == 5
|
||||
|
||||
|
||||
class TestVerificationCodeBasics:
|
||||
"""基础构造和 slots 测试."""
|
||||
|
||||
def test_direct_construction(self):
|
||||
now = datetime.now(timezone.utc)
|
||||
vc = VerificationCode(
|
||||
id="abc123",
|
||||
recipient="test@test.com",
|
||||
code="000000",
|
||||
code_type="email_bind",
|
||||
expires_at=now + timedelta(minutes=5),
|
||||
used_at=None,
|
||||
attempts=0,
|
||||
created_at=now,
|
||||
)
|
||||
assert vc.id == "abc123"
|
||||
assert vc.recipient == "test@test.com"
|
||||
assert vc.code == "000000"
|
||||
|
||||
def test_slots_no_extra_attrs(self):
|
||||
vc = VerificationCode.create("a@b.com", "email_login")
|
||||
with pytest.raises((AttributeError, TypeError)):
|
||||
vc.new_field = "value" # type: ignore[attr-defined]
|
||||
|
||||
def test_equality_same_id(self):
|
||||
now = datetime(2026, 1, 1, 12, 0, 0, tzinfo=timezone.utc)
|
||||
vc1 = VerificationCode(
|
||||
id="same",
|
||||
recipient="a@b.com",
|
||||
code="111",
|
||||
code_type="email_login",
|
||||
expires_at=now,
|
||||
created_at=now,
|
||||
)
|
||||
vc2 = VerificationCode(
|
||||
id="same",
|
||||
recipient="a@b.com",
|
||||
code="111",
|
||||
code_type="email_login",
|
||||
expires_at=now,
|
||||
created_at=now,
|
||||
)
|
||||
assert vc1 == vc2
|
||||
|
||||
def test_equality_different_id(self):
|
||||
now = datetime.now(timezone.utc)
|
||||
vc1 = VerificationCode(
|
||||
id="id1",
|
||||
recipient="a@b.com",
|
||||
code="111",
|
||||
code_type="email_login",
|
||||
expires_at=now,
|
||||
)
|
||||
vc2 = VerificationCode(
|
||||
id="id2",
|
||||
recipient="a@b.com",
|
||||
code="111",
|
||||
code_type="email_login",
|
||||
expires_at=now,
|
||||
)
|
||||
assert vc1 != vc2
|
||||
Reference in New Issue
Block a user