188 lines
11 KiB
Python
188 lines
11 KiB
Python
"""End-to-end check of the Phase 1 API with the model replaced by canned results. No GPU or network needed.
|
|
|
|
Run: python tests/test_phase1.py
|
|
"""
|
|
import io
|
|
import os
|
|
import sys
|
|
import tempfile
|
|
from pathlib import Path
|
|
|
|
ROOT = Path(__file__).resolve().parent.parent
|
|
sys.path.insert(0, str(ROOT))
|
|
TMP = tempfile.mkdtemp(prefix="parentcalendar-test-")
|
|
os.environ.update(DATA_DIR=TMP, PUBLIC_URL="https://calendar.example.com", EXTRACT_IGNORE="")
|
|
|
|
from fastapi.testclient import TestClient # noqa: E402
|
|
from PIL import Image # noqa: E402
|
|
|
|
from parentcalendar import app as app_module, db, extraction, scans # noqa: E402
|
|
|
|
app_module.START_WORKER = False
|
|
|
|
|
|
def png(color):
|
|
buf = io.BytesIO()
|
|
Image.new("RGB", (400, 300), color).save(buf, "PNG")
|
|
return buf.getvalue()
|
|
|
|
|
|
def ev(date, title, **kw):
|
|
return {"date": date, "end_date": None, "all_day": True, "start_time": None, "end_time": None, "title": title,
|
|
"notes": None, "location": None, "kind": "event", "applies": True, "source_text": title,
|
|
"confidence": 0.9, "cell_bbox": [0, 0.2, 1, 0.2], "flags": [], **kw}
|
|
|
|
|
|
CANNED = [] # the next fake extraction results, one per page
|
|
|
|
|
|
def fake_extract(img, hint, double_read=None, ignore=None):
|
|
result = CANNED.pop(0)
|
|
return {"model": "fake", "method": "page", "seconds": 0.1, "usage": {"input_tokens": 1, "output_tokens": 1},
|
|
"result": {"date_range": {"start": "2026-10-01", "end": "2026-10-31"}, "menu_items": [], **result},
|
|
"image": img}
|
|
|
|
|
|
extraction.extract_image = fake_extract
|
|
|
|
|
|
def upload(c, source_id, image, result, month="2026-10"):
|
|
r = c.post("/api/scans", data={"source_id": source_id, "month": month}, files=[("files", ("p.png", image, "image/png"))])
|
|
assert r.status_code == 200, r.text
|
|
CANNED.append(result)
|
|
with db.connect() as conn:
|
|
scans.process_scan(conn, r.json()["id"])
|
|
return c.get(f"/api/scans/{r.json()['id']}").json()
|
|
|
|
|
|
def test():
|
|
with TestClient(app_module.app) as c:
|
|
# Household setup
|
|
s = c.post("/api/children", json={"display_name": "Sam", "color": "#ff8800"}).json()
|
|
sam = s["children"][0]["id"]
|
|
s = c.post("/api/calendars", json={"name": "Sam's school", "child_ids": [sam], "alarm_minutes": 60}).json()
|
|
cal = s["calendars"][0]["id"]
|
|
assert len(s["calendars"][0]["feed_token"]) >= 32 # 192 random bits
|
|
s = c.post("/api/sources", json={"name": "District", "child_ids": [sam]}).json()
|
|
district = s["sources"][0]["id"]
|
|
s = c.post("/api/sources", json={"name": "Lunch", "layout_type": "menu", "child_ids": [sam]}).json()
|
|
menu_src = s["sources"][1]["id"]
|
|
assert c.put("/api/user", json={"timezone": "America/Chicago"}).json()["timezone"] == "America/Chicago"
|
|
assert c.put("/api/user", json={"timezone": "Mars/Base"}).status_code == 422
|
|
|
|
# Upload: bad files are rejected; the same image again returns the same scan (no second model run)
|
|
bad = c.post("/api/scans", data={"source_id": district, "month": "2026-10"}, files=[("files", ("x.png", b"nope", "image/png"))])
|
|
assert bad.status_code == 400
|
|
scan = upload(c, district, png("white"), {"events": [
|
|
ev("2026-10-02", "Spirit Day"),
|
|
ev("2026-10-20", "PTO", all_day=False, start_time="16:00", location="Library"),
|
|
ev("2026-10-29", "End of Q.1", flags=["second read differs: date"]),
|
|
ev("2026-10-14", "Grade 5 field trip", applies=False),
|
|
ev("2026-10-30", "No School", kind="no_school"),
|
|
]})
|
|
again = c.post("/api/scans", data={"source_id": district, "month": "2026-10"},
|
|
files=[("files", ("p.png", png("white"), "image/png"))]).json()
|
|
assert again["id"] == scan["id"]
|
|
assert scan["status"] == "parsed" and len(scan["events"]) == 5
|
|
by = {e["title"]: e for e in scan["events"]}
|
|
assert by["PTO"]["child_ids"] == [sam] and by["End of Q.1"]["flags"] == ["second read differs: date"]
|
|
assert by["Grade 5 field trip"]["include"] is False # not for this child: unchecked
|
|
assert c.get(f"/api/scans/{scan['id']}/pages/1").status_code == 200
|
|
|
|
# Review: edit, validation, undo, bulk, add
|
|
r = c.patch(f"/api/events/{by['End of Q.1']['id']}", json={"date": "2026-10-28", "end_date": "2026-10-20"})
|
|
assert r.status_code == 422 # end before start
|
|
c.patch(f"/api/events/{by['End of Q.1']['id']}", json={"date": "2026-10-28"})
|
|
c.patch(f"/api/events/{by['Spirit Day']['id']}", json={"title": "Spirit Day!"})
|
|
scan = c.post(f"/api/scans/{scan['id']}/undo").json() # undoes the title only
|
|
titles = {e["title"]: e for e in scan["events"]}
|
|
assert "Spirit Day" in titles and titles["End of Q.1"]["date"] == "2026-10-28" and scan["status"] == "reviewed"
|
|
c.post("/api/events/bulk", json={"ids": [by["Spirit Day"]["id"], by["PTO"]["id"]], "delete": True})
|
|
scan = c.post(f"/api/scans/{scan['id']}/undo").json() # both come back together
|
|
assert all(e["status"] == "draft" for e in scan["events"])
|
|
added = c.post("/api/events", json={"scan_id": scan["id"], "date": "2026-10-09", "title": "Spirit Day",
|
|
"child_ids": [sam]}).json()
|
|
assert added["status"] == "draft" and added["all_day"] is True
|
|
|
|
# Publish: checked drafts are confirmed, the unchecked one is dropped
|
|
scan = c.post(f"/api/scans/{scan['id']}/publish").json()
|
|
assert scan["status"] == "published"
|
|
assert sorted(e["title"] for e in scan["events"]) == ["End of Q.1", "No School", "PTO", "Spirit Day", "Spirit Day"]
|
|
assert c.post(f"/api/scans/{scan['id']}/publish").status_code == 409
|
|
|
|
# Export: first download has everything; the next only what changed
|
|
ics1 = c.get(f"/api/calendars/{cal}/export.ics").text
|
|
assert ics1.count("BEGIN:VEVENT") == 5 and "TZID=America/Chicago:20261020T160000" in ics1
|
|
assert c.get(f"/api/calendars/{cal}/export.ics").text.count("BEGIN:VEVENT") == 0
|
|
pto = next(e for e in scan["events"] if e["title"] == "PTO")
|
|
assert c.patch(f"/api/events/{pto['id']}", json={"end_time": "17:00"}).json()["sequence"] == 1
|
|
ics2 = c.get(f"/api/calendars/{cal}/export.ics").text
|
|
assert ics2.count("BEGIN:VEVENT") == 1 and pto["uid"] in ics2.replace("\r\n ", "") and "SEQUENCE:1" in ics2
|
|
c.delete(f"/api/events/{pto['id']}")
|
|
assert "STATUS:CANCELLED" in c.get(f"/api/calendars/{cal}/export.ics").text
|
|
assert c.get(f"/api/calendars/{cal}/export.ics?everything=true").text.count("BEGIN:VEVENT") == 4
|
|
|
|
# A revised calendar: the matching event keeps its UID and is updated, not duplicated
|
|
spirit = next(e for e in scan["events"] if e["title"] == "Spirit Day" and e["date"] == "2026-10-02")
|
|
rescan = upload(c, district, png("gray"), {"events": [
|
|
ev("2026-10-02", "Spirit Day", notes="Wear purple"), ev("2026-10-23", "Spirit Day")]})
|
|
m = {e["date"]: e for e in rescan["events"]}
|
|
assert m["2026-10-02"]["match"] == "updated" and m["2026-10-02"]["replaces_id"] == spirit["id"]
|
|
assert m["2026-10-23"]["match"] == "new"
|
|
c.post(f"/api/scans/{rescan['id']}/publish")
|
|
events = c.get("/api/events", params={"calendar_id": cal}).json()
|
|
spirits = [e for e in events if e["date"] == "2026-10-02"]
|
|
assert len(spirits) == 1 and spirits[0]["uid"] == spirit["uid"] and spirits[0]["notes"] == "Wear purple"
|
|
assert spirits[0]["sequence"] == 1
|
|
|
|
# Lunch: menu days, greyed closures, picks as events
|
|
menu = upload(c, menu_src, png("yellow"), {"events": [], "menu_items": [
|
|
{"date": "2026-10-05", "items": ["Pork Chopette", "Corn"], "no_lunch": False, "flags": [],
|
|
"reads": [{"items": ["Pork Chopette", "Corn"], "no_lunch": False},
|
|
{"items": ["Pork Chop", "Corn"], "no_lunch": False}]},
|
|
{"date": "2026-10-30", "items": [], "no_lunch": True, "flags": []}]})
|
|
assert [r["items"][0] for r in menu["menu_items"][0]["reads"]] == ["Pork Chopette", "Pork Chop"] # both reads kept
|
|
assert menu["menu_items"][1]["reads"] is None
|
|
c.post(f"/api/scans/{menu['id']}/publish")
|
|
# After publishing, a mistaken "no lunch" keeps the food, and unticking it brings the day back
|
|
item = menu["menu_items"][0]["id"]
|
|
r = c.patch(f"/api/menu_items/{item}", json={"items": ["Pork Chopette", "Corn"], "no_lunch": True}).json()
|
|
assert r["no_lunch"] is True and r["items"] == ["Pork Chopette", "Corn"], r
|
|
days = {d["date"]: d for d in c.get("/api/lunch", params={"child_id": sam, "month": "2026-10"}).json()}
|
|
assert days["2026-10-05"]["greyed"] is True
|
|
c.patch(f"/api/menu_items/{item}", json={"items": ["Pork Chopette", "Corn"], "no_lunch": False})
|
|
days = {d["date"]: d for d in c.get("/api/lunch", params={"child_id": sam, "month": "2026-10"}).json()}
|
|
assert days["2026-10-05"]["greyed"] is False and days["2026-10-30"]["greyed"] is True
|
|
assert days["2026-10-30"]["closed"] == "No School"
|
|
assert c.post("/api/lunch", json={"child_id": sam, "date": "2026-10-30", "picked": True}).status_code == 409
|
|
days = c.post("/api/lunch", json={"child_id": sam, "date": "2026-10-05", "picked": True}).json()
|
|
assert next(d for d in days if d["date"] == "2026-10-05")["picked"] is True
|
|
pack = [e for e in c.get("/api/events", params={"calendar_id": cal}).json() if e["kind"] == "pack_lunch"]
|
|
assert len(pack) == 1 and pack[0]["title"] == "Pack lunch — Sam" and pack[0]["all_day"] is False and pack[0]["start_time"] == "08:00" and pack[0]["notes"] == "Pork Chopette"
|
|
c.post("/api/lunch", json={"child_id": sam, "date": "2026-10-05", "picked": False})
|
|
c.post("/api/lunch", json={"child_id": sam, "date": "2026-10-05", "picked": True})
|
|
pack2 = [e for e in c.get("/api/events", params={"calendar_id": cal}).json() if e["kind"] == "pack_lunch"]
|
|
assert pack2[0]["uid"] == pack[0]["uid"] and pack2[0]["sequence"] == 2 # same event, re-picked
|
|
# Select all skips the no-school day instead of failing; clear all unpicks
|
|
c.post("/api/lunch", json={"child_id": sam, "date": "2026-10-05", "picked": False})
|
|
days = {d["date"]: d for d in c.post("/api/lunch/bulk", json={
|
|
"child_id": sam, "dates": ["2026-10-05", "2026-10-30"], "picked": True}).json()}
|
|
assert days["2026-10-05"]["picked"] is True and days["2026-10-30"]["picked"] is False
|
|
days = {d["date"]: d for d in c.post("/api/lunch/bulk", json={
|
|
"child_id": sam, "dates": ["2026-10-05"], "picked": False}).json()}
|
|
assert days["2026-10-05"]["picked"] is False
|
|
assert c.post("/api/lunch/bulk", json={"child_id": sam, "dates": [], "picked": True}).status_code == 422
|
|
|
|
# A failed extraction marks the scan failed and can be retried
|
|
r = c.post("/api/scans", data={"source_id": district, "month": "2026-11"},
|
|
files=[("files", ("p.png", png("blue"), "image/png"))]).json()
|
|
with db.connect() as conn:
|
|
conn.execute("UPDATE scan SET status = 'failed', error = 'boom' WHERE id = ?", (r["id"],))
|
|
conn.commit()
|
|
assert c.post(f"/api/scans/{r['id']}/retry").json()["status"] == "queued"
|
|
print("phase 1 api test ok")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
test()
|