171 lines
7.6 KiB
Python
171 lines
7.6 KiB
Python
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 '<script src="static/today-rollover.js"></script>' 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
|