stackchain-dashboard/tests/test_today_rollover.py
timmy 14a3e1ee8d
All checks were successful
CI / lint (pull_request) Successful in 1m33s
CI / build-release (pull_request) Successful in 6s
CI / release-candidate (pull_request) Has been skipped
feat: review Today work at day rollover (Closes #603)
2026-08-12 00:38:13 +00:00

171 lines
7.6 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.

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 yesterdays unfinished work" in dashboard
assert "todaySync.enqueueRollover" in dashboard
assert ".plan-today-header button { min-width:44px; min-height:44px;" in css