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>
This commit is contained in:
@@ -1,7 +1,8 @@
|
||||
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
|
||||
from app.repository import ConflictError, JsonRepository, period_window
|
||||
|
||||
|
||||
def task_payload():
|
||||
@@ -46,3 +47,136 @@ def test_daily_mixed_metrics_history_and_idempotent_archive(tmp_path):
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user