Files
personal-progress-monitor/tests/test_repository.py
MobKBK bcc2abe623 feat: complete frozen MVP — period-aware stats, layout-driven UI, quick checkin
- Fix first daily record 422 (expectedVersion now accepts 0)
- Add period_window + summarize; /api/summary and /api/tasks/{id}/summary
  aggregate each metric inside its own day/week/month/lifetime window,
  reading raw facts only (never the rebuildable month archives)
- Drive frontend rendering from per-page layout JSON with a component
  registry; scope/cardinality enforced server-side, invalid layouts fall back
- Add quick checkin and duration input in H:M:S that round-trips integer seconds
- Fix heatmap/completedAt timezone drift to Asia/Shanghai
- Add server-side session TTL (30d) matching the login cookie
- Handle date.min/date.max boundaries without OverflowError
- Half-up rounding to match browser Math.round

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-24 07:15:50 +08:00

183 lines
9.1 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
from datetime import date, timedelta
from app.auth import SessionManager, make_password_hash
from app.models import DailyRecordInput, MetricValue, TaskInput
from app.repository import ConflictError, JsonRepository, period_window
def task_payload():
return TaskInput.model_validate({
"name": "学习计划", "startDate": "2026-08-01", "priority": 2,
"metrics": [
{"name": "打卡", "kind": "checkin", "period": "day"},
{"name": "刷题", "kind": "quantity", "period": "lifetime", "target": 100, "unit": "题"},
{"name": "专注", "kind": "duration", "period": "day", "target": 3600},
],
})
def test_crud_revision_soft_delete_and_purge(tmp_path):
repo = JsonRepository(tmp_path)
task = repo.create_task(task_payload())
assert len(task["metrics"]) == 3
updated = repo.update_task(task["taskId"], task_payload().model_copy(update={"name": "改名后的计划"}), task["version"])
assert updated["taskId"] == task["taskId"] and updated["revision"] == 2
assert repo.history(task["taskId"], date(2026, 8, 1), date(2026, 8, 1))["revisions"]
deleted = repo.soft_delete_task(task["taskId"], updated["version"])
assert repo.list_tasks()["items"] == []
repo.restore_task(task["taskId"], deleted["version"])
repo.soft_delete_task(task["taskId"], deleted["version"] + 1)
repo.purge_task(task["taskId"], task["taskId"])
assert repo.list_tasks(include_deleted=True)["items"] == []
def test_daily_mixed_metrics_history_and_idempotent_archive(tmp_path):
repo = JsonRepository(tmp_path)
task = repo.create_task(task_payload())
values = [MetricValue(metricId=item["metricId"], value=1 if item["kind"] == "checkin" else 120) for item in task["metrics"]]
record = repo.upsert_daily(task["taskId"], date(2026, 8, 10), DailyRecordInput(note="不错", values=values))
assert record["version"] == 1
repo.close_day(date(2026, 8, 10)); repo.close_day(date(2026, 8, 10))
history = repo.history(task["taskId"], date(2026, 8, 10), date(2026, 8, 10))
assert history["records"][0]["note"] == "不错"
try:
repo.upsert_daily(task["taskId"], date(2026, 8, 10), DailyRecordInput(expectedVersion=0, values=[]))
except ConflictError:
pass
else:
raise AssertionError("expected version conflict")
def test_first_daily_record_accepts_expected_version_zero(tmp_path):
repo = JsonRepository(tmp_path)
task = repo.create_task(task_payload())
assert repo.get_daily(task["taskId"], date(2026, 8, 11))["version"] == 0
record = repo.upsert_daily(task["taskId"], date(2026, 8, 11), DailyRecordInput(expectedVersion=0, values=[]))
assert record["version"] == 1
def test_period_window_boundaries():
ref = date(2026, 8, 12)
assert period_window("day", ref) == (ref, ref)
week_start, week_end = period_window("week", ref)
assert week_start.weekday() == 0 and (week_end - week_start).days == 6
assert week_start <= ref <= week_end
assert period_window("month", ref) == (date(2026, 8, 1), date(2026, 8, 31))
assert period_window("month", date(2026, 2, 5)) == (date(2026, 2, 1), date(2026, 2, 28))
assert period_window("month", date(2028, 2, 5)) == (date(2028, 2, 1), date(2028, 2,29))
assert period_window("month", date(2026, 12, 9)) == (date(2026, 12, 1), date(2026, 12, 31))
assert period_window("lifetime", ref, date(2026, 8, 1)) == (date(2026, 8, 1), ref)
assert period_window("lifetime", date(2026, 7, 1), date(2026, 8, 1)) is None, "任务开始前的 lifetime 窗口应为 None"
def test_period_window_date_extremes_do_not_overflow():
assert period_window("day", date.max) == (date.max, date.max)
assert period_window("day", date.min) == (date.min, date.min)
assert period_window("month", date(9999, 12, 1)) == (date(9999, 12, 1), date(9999, 12, 31))
week = period_window("week", date(9999, 12, 31))
assert week[0] <= date(9999, 12, 31) <= week[1]
# 1999-12-31 是周五;week 起点应是周一 9999-12-27,终点 clip 到 date.max
assert week[0] == date(9999, 12, 27)
assert week[1] == date.max
assert period_window("month", date(1, 1, 1)) == (date(1, 1, 1), date(1, 1, 31))
def test_summary_counts_each_metric_inside_its_own_period(tmp_path):
repo = JsonRepository(tmp_path)
task = repo.create_task(TaskInput.model_validate({
"name": "周期检查", "startDate": "2026-08-01",
"metrics": [
{"name": "每日打卡", "kind": "checkin", "period": "day"},
{"name": "本月刷题", "kind": "quantity", "period": "month", "target": 100, "unit": "题"},
{"name": "累计专注", "kind": "duration", "period": "lifetime", "target": 7200},
],
}))
day_id, month_id, life_id = [metric["metricId"] for metric in task["metrics"]]
for day, quantity, seconds in [(date(2026, 7, 25), 10, 600), (date(2026, 8, 5), 30, 1200), (date(2026, 8, 20), 20, 1800)]:
repo.upsert_daily(task["taskId"], day, DailyRecordInput(values=[
MetricValue(metricId=day_id, value=1),
MetricValue(metricId=month_id, value=quantity),
MetricValue(metricId=life_id, value=seconds),
]))
summary = repo.summarize(date(2026, 8, 20))["tasks"][task["taskId"]]
by_id = {metric["metricId"]: metric for metric in summary["metrics"]}
assert by_id[day_id]["value"] == 1, "day 周期只能统计当天"
assert by_id[month_id]["value"] == 50 and by_id[month_id]["percent"] == 50, "month 周期不能带上 7 月"
assert by_id[life_id]["value"] == 3000 and by_id[life_id]["percent"] == 42, "lifetime 不能早于任务开始日期"
assert summary["progress"] == 64 and summary["today"]["recorded"] is True
def test_summary_without_targets_has_no_progress(tmp_path):
repo = JsonRepository(tmp_path)
task = repo.create_task(TaskInput.model_validate({
"name": "无上限", "startDate": "2026-08-01",
"metrics": [{"name": "写作", "kind": "quantity", "period": "lifetime", "unit": "字"}],
}))
summary = repo.summarize(date(2026, 8, 20))["tasks"][task["taskId"]]
assert summary["progress"] is None
assert summary["metrics"][0]["percent"] is None
assert summary["today"]["recorded"] is False
def test_summary_skips_soft_deleted_tasks(tmp_path):
repo = JsonRepository(tmp_path)
task = repo.create_task(task_payload())
repo.soft_delete_task(task["taskId"], task["version"])
assert repo.summarize(date(2026, 8, 20))["tasks"] == {}
def test_summary_excludes_facts_after_reference_date(tmp_path):
repo = JsonRepository(tmp_path)
task = repo.create_task(TaskInput.model_validate({
"name": "月中查询", "startDate": "2026-08-01",
"metrics": [{"name": "本月", "kind": "quantity", "period": "month", "target": 100, "unit": "次"}],
}))
metric_id = task["metrics"][0]["metricId"]
# 8 月 5 日一条,8 月 25 日(在 ref=8-20 之后)一条
for day, value in [(date(2026, 8, 5), 30), (date(2026, 8, 25), 20)]:
repo.upsert_daily(task["taskId"], day, DailyRecordInput(values=[MetricValue(metricId=metric_id, value=value)]))
metric = repo.summarize(date(2026, 8, 20))["tasks"][task["taskId"]]["metrics"][0]
assert metric["value"] == 30, "ref 之后的事实不得计入历史汇总"
def test_summary_lifetime_ignores_facts_before_task_start(tmp_path):
repo = JsonRepository(tmp_path)
task = repo.create_task(TaskInput.model_validate({
"name": "未来开始", "startDate": "2026-08-15",
"metrics": [{"name": "累计", "kind": "quantity", "period": "lifetime", "target": 100, "unit": "次"}],
}))
metric_id = task["metrics"][0]["metricId"]
repo.upsert_daily(task["taskId"], date(2026, 8, 10), DailyRecordInput(values=[MetricValue(metricId=metric_id, value=50)]))
metric = repo.summarize(date(2026, 8, 20))["tasks"][task["taskId"]]["metrics"][0]
assert metric["value"] == 0, "任务开始日期之前的事实不得计入 lifetime"
assert metric["windowStart"] == "2026-08-15"
def test_percent_half_up_rounding(tmp_path):
repo = JsonRepository(tmp_path)
task = repo.create_task(TaskInput.model_validate({
"name": "舍入", "startDate": "2026-08-01",
"metrics": [{"name": "进度", "kind": "quantity", "period": "lifetime", "target": 8, "unit": "次"}],
}))
metric_id = task["metrics"][0]["metricId"]
repo.upsert_daily(task["taskId"], date(2026, 8, 1), DailyRecordInput(values=[MetricValue(metricId=metric_id, value=1)]))
metric = repo.summarize(date(2026, 8, 1))["tasks"][task["taskId"]]["metrics"][0]
assert metric["percent"] == 13, "1/8 应四舍五入进位到 13%,而非四舍六入的 12%"
def test_session_expiry():
manager = SessionManager(make_password_hash("secret"), ttl_seconds=10)
session_id, session = manager.login("secret")
assert manager.get(session_id) is session
session.created_at -= 11 # 模拟 11 秒前的会话
assert manager.get(session_id) is None, "过期会话应当失效"
def test_session_ttl_keeps_fresh_sessions():
manager = SessionManager(make_password_hash("secret"), ttl_seconds=10)
session_id, session = manager.login("secret")
session.created_at -= 5
assert manager.get(session_id) is session