"""第 4 周 · 练习 4 的测试(已给出,不要改):重构目标的验收标准。
用法:uv run pytest exercises/week4/w4_04_refactor_kata -q
现在的状态:domain.py / repository.py / service.py 还不存在,所以整个文件被 **skip**
(`pytest.importorskip` 让收集阶段不报错)。你的任务就是让它们出现并全绿。
读法:这份测试只碰三样东西——纯函数(domain)、`Protocol` 的两个实现(repository)、
注入了假仓储的用例(service)。没有一个测试需要 stdout 或全局连接,
这就是"分层 + 依赖注入"换来的可测试性。
接口约定:见同目录 README.md 的"目标接口"一节。
"""
from collections.abc import Iterator
from pathlib import Path
from typing import Any
import pytest
domain = pytest.importorskip("domain", reason="还没重构出 domain.py")
repository = pytest.importorskip("repository", reason="还没重构出 repository.py")
service = pytest.importorskip("service", reason="还没重构出 service.py")
FIXED_TIME = "2026-01-01T00:00:00+00:00"
def make_note(title: str, body: str = "", tags: tuple[str, ...] = ()) -> Any:
"""造一条"还没入库"的笔记(id=0 交给仓储分配)。"""
return domain.Note(id=0, title=title, body=body, tags=tags, created_at=FIXED_TIME)
@pytest.fixture(params=["memory", "sqlite"])
def repo(request: pytest.FixtureRequest, tmp_path: Path) -> Iterator[Any]:
"""同一套测试跑两种实现——这就是 Protocol 的意义。"""
if request.param == "memory":
yield repository.InMemoryNoteRepository()
return
sqlite_repo = repository.SqliteNoteRepository(tmp_path / "notes.db")
try:
yield sqlite_repo
finally:
sqlite_repo.close()
@pytest.fixture
def svc(repo: Any) -> Any:
"""用例层:构造函数注入仓储 + 固定时钟,输出可预测。"""
return service.NoteService(repo, clock=lambda: FIXED_TIME)
# ---- 领域层:纯函数与不可变模型,不需要任何 I/O ----
def test_note_is_a_frozen_dataclass() -> None:
import dataclasses
note = make_note("读书笔记", tags=("python",))
assert dataclasses.is_dataclass(note)
assert note.tags == ("python",)
with pytest.raises(dataclasses.FrozenInstanceError):
note.title = "改不了"
def test_validate_title_strips_and_rejects_empty() -> None:
assert domain.validate_title(" 读书笔记 ") == "读书笔记"
with pytest.raises(domain.NoteValidationError):
domain.validate_title(" ")
with pytest.raises(domain.NoteValidationError):
domain.validate_title("x" * 101)
def test_normalize_tags_accepts_string_or_iterable() -> None:
assert domain.normalize_tags("Python, web , python,") == ("python", "web")
assert domain.normalize_tags(["Web", "web", " python "]) == ("python", "web")
assert domain.normalize_tags("") == ()
def test_format_note_is_a_pure_function() -> None:
note = domain.Note(
id=3,
title="读书笔记",
body="正文",
tags=("python", "web"),
created_at=FIXED_TIME,
)
assert domain.format_note(note) == "#3 读书笔记 [python,web]"
assert domain.format_note(make_note("无标签")) == "#0 无标签 []"
def test_domain_exception_hierarchy() -> None:
assert issubclass(domain.NoteNotFoundError, domain.NoteError)
assert issubclass(domain.NoteValidationError, domain.NoteError)
assert issubclass(domain.DuplicateNoteError, domain.NoteError)
assert str(domain.NoteNotFoundError(7)) == "note 7 not found"
def test_summarize_counts_tags_and_longest_title() -> None:
notes = [
domain.Note(1, "短", "", ("python",), FIXED_TIME),
domain.Note(2, "长一点的标题", "", ("python", "web"), FIXED_TIME),
]
stats = domain.summarize(notes)
assert stats.total == 2
assert stats.tags == {"python": 2, "web": 1}
assert stats.longest_title == "长一点的标题"
assert domain.summarize([]).total == 0
assert domain.summarize([]).longest_title is None
# ---- 仓储层:两种实现,同一套断言 ----
def test_repository_add_assigns_ids_and_get_returns_note(repo: Any) -> None:
first = repo.add(make_note("第一条"))
second = repo.add(make_note("第二条"))
assert (first.id, second.id) == (1, 2)
assert repo.get(1).title == "第一条"
assert repo.get(999) is None
def test_repository_list_all_is_sorted_by_id(repo: Any) -> None:
for title in ("A", "B", "C"):
repo.add(make_note(title))
assert [note.title for note in repo.list_all()] == ["A", "B", "C"]
def test_repository_search_is_case_insensitive(repo: Any) -> None:
repo.add(make_note("Python 笔记", body="讲生成器"))
repo.add(make_note("英语笔记", body="单词表"))
assert [note.id for note in repo.search("python")] == [1]
assert [note.id for note in repo.search("单词")] == [2]
assert repo.search("不存在") == []
def test_repository_delete_reports_whether_it_deleted(repo: Any) -> None:
repo.add(make_note("待删除"))
assert repo.delete(1) is True
assert repo.delete(1) is False
assert repo.list_all() == []
def test_repository_roundtrips_tags(repo: Any) -> None:
saved = repo.add(make_note("带标签", tags=("python", "web")))
assert repo.get(saved.id).tags == ("python", "web")
assert repo.get(repo.add(make_note("无标签")).id).tags == ()
def test_sqlite_repository_persists_across_reopen(tmp_path: Path) -> None:
db_path = tmp_path / "notes.db"
first = repository.SqliteNoteRepository(db_path)
first.add(make_note("落盘了", tags=("python",)))
first.close()
second = repository.SqliteNoteRepository(db_path)
try:
assert [note.title for note in second.list_all()] == ["落盘了"]
assert second.get(1).tags == ("python",)
finally:
second.close()
def test_sqlite_repository_survives_quotes_in_input(tmp_path: Path) -> None:
# 参数化查询之后,标题里的引号只是普通字符
repo = repository.SqliteNoteRepository(tmp_path / "notes.db")
try:
nasty = "it's a '); DROP TABLE notes; -- 笔记"
saved = repo.add(make_note(nasty))
assert repo.get(saved.id).title == nasty
assert len(repo.list_all()) == 1
finally:
repo.close()
# ---- 用例层:注入假仓储与固定时钟,不碰数据库也不碰 stdout ----
def test_service_add_note_validates_and_normalizes(svc: Any) -> None:
note = svc.add_note(" 读书笔记 ", "正文", "Python, web ,python")
assert note.id == 1
assert note.title == "读书笔记"
assert note.tags == ("python", "web")
assert note.created_at == FIXED_TIME
with pytest.raises(domain.NoteValidationError):
svc.add_note(" ")
def test_service_rejects_duplicate_title(svc: Any) -> None:
svc.add_note("读书笔记")
with pytest.raises(domain.DuplicateNoteError):
svc.add_note(" 读书笔记 ")
def test_service_get_and_delete_raise_not_found(svc: Any) -> None:
with pytest.raises(domain.NoteNotFoundError):
svc.get_note(1)
note = svc.add_note("在的")
assert svc.get_note(note.id).title == "在的"
svc.delete_note(note.id)
with pytest.raises(domain.NoteNotFoundError):
svc.delete_note(note.id)
def test_service_list_notes_filters_by_tag(svc: Any) -> None:
svc.add_note("A", tags="python")
svc.add_note("B", tags="web")
svc.add_note("C", tags="python,web")
assert [note.title for note in svc.list_notes()] == ["A", "B", "C"]
assert [note.title for note in svc.list_notes(tag="Python")] == ["A", "C"]
def test_service_search_requires_a_keyword(svc: Any) -> None:
svc.add_note("Python 笔记", body="讲生成器")
assert [note.id for note in svc.search_notes("PYTHON")] == [1]
with pytest.raises(domain.NoteValidationError):
svc.search_notes(" ")
def test_service_stats_uses_the_domain_summary(svc: Any) -> None:
svc.add_note("短", tags="python")
svc.add_note("长一点的标题", tags="python,web")
stats = svc.stats()
assert stats.total == 2
assert stats.tags == {"python": 2, "web": 1}
assert stats.longest_title == "长一点的标题"
def test_service_layer_has_no_io() -> None:
# 服务层只依赖 Protocol:既不 import sqlite3,也不 print
source = Path(service.__file__).read_text(encoding="utf-8")
assert "import sqlite3" not in source
assert "print(" not in source