import json import subprocess from pathlib import Path import pytest from src import main from src.today_store import TodayStore ROLLOVER = Path(__file__).parents[1] / "frontend" / "today-rollover.js" SYNC = Path(__file__).parents[1] / "frontend" / "today-sync.js" def run_node(script): return json.loads(subprocess.run( ["node", "-e", script], check=True, capture_output=True, text=True ).stdout) def test_rollover_replaces_a_stale_plan_atomically_and_is_idempotent(tmp_path): store = TodayStore(tmp_path / "today.sqlite3", limit=3) store.apply_batch("timmy", [ {"operation_id": "seed-1", "action": "add", "item_id": "issue:r:1:"}, {"operation_id": "seed-2", "action": "add", "item_id": "issue:r:2:"}, {"operation_id": "seed-plan", "action": "configure", "item_id": "plan", "capacity_minutes": 180, "estimates": {"issue:r:1:": 60, "issue:r:2:": 45}}, ]) operation = { "operation_id": "roll-2026-08-13", "action": "rollover", "item_id": "plan", "plan_date": "2026-08-13", "timezone": "America/New_York", "ids": ["issue:r:2:"], "capacity_minutes": 120, "estimates": {"issue:r:2:": 40}, "base_revision": 3, } rolled = store.apply_batch("timmy", [operation]) replay = store.apply_batch("timmy", [operation]) assert rolled == { "revision": 4, "ids": ["issue:r:2:"], "capacity_minutes": 120, "estimates": {"issue:r:2:": 40}, "plan_date": "2026-08-13", "timezone": "America/New_York", "accepted_operation_ids": ["roll-2026-08-13"], "duplicate_operation_ids": [], "rejected_operations": [], } assert replay["revision"] == 4 assert replay["duplicate_operation_ids"] == ["roll-2026-08-13"] assert TodayStore(store.path).get("timmy")["plan_date"] == "2026-08-13" edited = store.apply("timmy", "after-roll", "add", "issue:r:3:") assert edited["plan_date"] == "2026-08-13" assert edited["timezone"] == "America/New_York" def test_stale_rollover_cannot_replace_a_newer_device_plan(tmp_path): store = TodayStore(tmp_path / "today.sqlite3") store.apply("timmy", "seed", "add", "issue:r:1:") store.apply("timmy", "newer", "add", "issue:r:2:") result = store.apply_batch("timmy", [{ "operation_id": "stale-roll", "action": "rollover", "item_id": "plan", "plan_date": "2026-08-13", "timezone": "UTC", "ids": ["issue:r:1:"], "capacity_minutes": 60, "estimates": {"issue:r:1:": 30}, "base_revision": 1, }]) assert result["ids"] == ["issue:r:1:", "issue:r:2:"] assert result["rejected_operations"] == [ {"operation_id": "stale-roll", "reason": "stale_intent"} ] def test_rollover_rejects_invalid_calendar_metadata_and_duplicate_items(tmp_path): store = TodayStore(tmp_path / "today.sqlite3") base = {"operation_id": "roll", "action": "rollover", "item_id": "plan", "plan_date": "08/13/2026", "timezone": "UTC", "ids": []} with pytest.raises(ValueError, match="plan_date"): store.apply_batch("timmy", [base]) with pytest.raises(ValueError, match="unique"): store.apply_batch("timmy", [{**base, "plan_date": "2026-08-13", "ids": ["issue:r:1:", "issue:r:1:"]}]) def test_today_api_model_accepts_a_bounded_rollover(): operation = main.TodayOperation( operation_id="roll", action="rollover", item_id="plan", base_revision=7, plan_date="2026-08-13", timezone="America/New_York", ids=["issue:r:1:"], capacity_minutes=120, estimates={"issue:r:1:": 45}, ) assert operation.model_dump()["plan_date"] == "2026-08-13" assert operation.model_dump()["timezone"] == "America/New_York" assert operation.model_dump()["ids"] == ["issue:r:1:"] def test_same_day_is_current_but_prior_and_legacy_plans_require_review(): script = f""" const create = require({json.dumps(str(ROLLOVER))}); const rollover = create({{ localDate:()=> '2026-08-13', timeZone:()=> 'America/New_York' }}); process.stdout.write(JSON.stringify([ rollover.reviewState({{plan_date:'2026-08-13',ids:['one']}}), rollover.reviewState({{plan_date:'2026-08-12',ids:['one']}}), rollover.reviewState({{ids:['one']}}), rollover.reviewState({{ids:[]}}), ])); """ assert run_node(script) == ["current", "stale", "legacy", "empty"] def test_rollover_preserves_selected_order_and_only_selected_estimates(): script = f""" const create = require({json.dumps(str(ROLLOVER))}); const rollover = create({{localDate:()=> '2026-08-13',timeZone:()=> 'America/New_York'}}); process.stdout.write(JSON.stringify(rollover.operation({{ operation_id:'roll-1', base_revision:7, selected_ids:['two','one'], capacity_minutes:120, estimates:{{one:30,two:45,removed:60}} }}))); """ assert run_node(script) == { "operation_id": "roll-1", "action": "rollover", "item_id": "plan", "base_revision": 7, "plan_date": "2026-08-13", "timezone": "America/New_York", "ids": ["two", "one"], "capacity_minutes": 120, "estimates": {"one": 30, "two": 45}, } def test_calendar_day_comes_from_local_parts_not_elapsed_hours(): script = f""" const create = require({json.dumps(str(ROLLOVER))}); const date = new Date('2026-11-01T05:30:00Z'); const rollover = create({{ now:()=>date, resolvedTimeZone:()=> 'America/New_York' }}); process.stdout.write(JSON.stringify({{date:rollover.localDate(),zone:rollover.timeZone()}})); """ assert run_node(script) == {"date": "2026-11-01", "zone": "America/New_York"} def test_rollover_is_one_durable_sync_operation_with_the_server_revision(): script = f""" const createSync = require({json.dumps(str(SYNC))}); const createRollover = require({json.dumps(str(ROLLOVER))}); const values = new Map(); let delivered; const storage = {{get length(){{return values.size}},key:i=>[...values.keys()][i]||null, getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}}; const sync=createSync({{storage,getLogin:()=> 'timmy',createOperationId:()=> 'roll-1', fetchJson:async(_url,options)=>{{delivered=JSON.parse(options.body).operations;return {{revision:8,ids:['two'],plan_date:'2026-08-13',timezone:'UTC',accepted_operation_ids:['roll-1'],duplicate_operation_ids:[],rejected_operations:[]}}}}, onRemoteIds:()=>{{}},onStatus:()=>{{}}}}); const rollover=createRollover({{localDate:()=> '2026-08-13',timeZone:()=> 'UTC'}}); const queued=sync.enqueueRollover(rollover.operation({{operation_id:'ignored',base_revision:7, selected_ids:['two'],capacity_minutes:90,estimates:{{two:45}}}})); (async()=>{{const before=sync.pending();await sync.flush();process.stdout.write(JSON.stringify({{queued,before,delivered,after:sync.pending()}}));}})(); """ result = run_node(script) assert result["queued"] is True assert result["before"] == result["delivered"] assert result["before"][0]["operation_id"] == "roll-1" assert result["before"][0]["action"] == "rollover" assert result["after"] == [] def test_rollover_review_is_wired_into_the_offline_mobile_shell(): root = Path(__file__).parents[1] index = (root / "frontend" / "index.html").read_text() worker = (root / "frontend" / "service-worker.js").read_text() dashboard = (root / "frontend" / "dashboard.js").read_text() css = (root / "frontend" / "dashboard.css").read_text() assert '' in index assert "BASE + 'static/today-rollover.js'" in worker assert "todayRollover.reviewState(plan)" in dashboard assert "Review yesterday’s unfinished work" in dashboard assert "todaySync.enqueueRollover" in dashboard assert ".plan-today-header button { min-width:44px; min-height:44px;" in css