feat: preserve Today timing ledger across handoff (Closes #1276)
This commit is contained in:
1 parent
e0d8ab9f10
commit
548b95ddb6
9 files changed
+267
-20
No files matched your search
@@ -23,5 +23,5 @@ def test_dashboard_packages_a_mobile_today_session_handoff():
|
||||
assert "Session offline · will retry." in today_bundle
|
||||
assert "Session continued on another device." in today_bundle
|
||||
assert "todaySessionSync = attachTodaySessionHandoff" in dashboard
|
||||
assert "todaySessionSync?.publish(snapshot)" in dashboard
|
||||
assert "todaySessionSync?.publish(timer.sessionSnapshot())" in dashboard
|
||||
assert "min-height:44px" in css
|
||||
@@ -58,6 +58,43 @@ const sync = createTodaySessionSync({
|
||||
}
|
||||
|
||||
|
||||
def test_claim_adopts_the_complete_timing_ledger_for_recap():
|
||||
result = run_node(
|
||||
f"""
|
||||
const createTimer = require({json.dumps(str(TIMER))});
|
||||
const values = new Map();
|
||||
const storage = {{getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value)}};
|
||||
const timer = createTimer({{storage,getLogin:()=> 'timmy',now:()=>100000}});
|
||||
const remote = {{
|
||||
revision:3,device_id:'desktop-a',identity:'issue:r:2:',elapsed_ms:90000,running:true,
|
||||
entries:[
|
||||
{{identity:'issue:r:1:',elapsed_ms:60000}},
|
||||
{{identity:'issue:r:2:',elapsed_ms:90000}},
|
||||
],
|
||||
}};
|
||||
const sync=createTodaySessionSync({{
|
||||
getDeviceId:()=> 'phone-b',timer,
|
||||
fetchJson:async (_url,options={{}})=>options.method ?
|
||||
{{...JSON.parse(options.body),revision:4,device_id:'phone-b'}} : remote,
|
||||
}});
|
||||
(async()=>{{
|
||||
await sync.refresh();
|
||||
await sync.claim();
|
||||
const snapshot=timer.sessionSnapshot();
|
||||
const recap=timer.recapEntries();
|
||||
process.stdout.write(JSON.stringify({{recap,snapshot}}));
|
||||
}})().catch(error=>{{console.error(error);process.exit(1);}});
|
||||
"""
|
||||
)
|
||||
|
||||
assert result["recap"] == [
|
||||
{"identity": "issue:r:1:", "elapsed_ms": 60_000},
|
||||
{"identity": "issue:r:2:", "elapsed_ms": 90_000},
|
||||
]
|
||||
assert result["snapshot"]["identity"] == "issue:r:2:"
|
||||
assert result["snapshot"]["running"] is True
|
||||
|
||||
|
||||
def test_second_device_sees_and_resumes_a_timed_break():
|
||||
result = run_node(
|
||||
r"""
|
||||
@@ -145,6 +182,25 @@ process.stdout.write(JSON.stringify({
|
||||
assert "private/repo" not in result["summary"]
|
||||
|
||||
|
||||
def test_multi_item_handoff_summary_shows_scope_and_total_without_titles():
|
||||
result = run_node(
|
||||
r"""
|
||||
const session={
|
||||
identity:'issue:private/repo:42:',elapsed_ms:90000,running:true,
|
||||
entries:[
|
||||
{identity:'issue:private/repo:41:',elapsed_ms:60000},
|
||||
{identity:'issue:private/repo:42:',elapsed_ms:90000},
|
||||
],
|
||||
};
|
||||
process.stdout.write(JSON.stringify(todaySessionHandoffSummary(session,{title:'Secret issue'})));
|
||||
"""
|
||||
)
|
||||
|
||||
assert result == "2 tracked items · 2 min total"
|
||||
assert "Secret" not in result
|
||||
assert "private/repo" not in result
|
||||
|
||||
|
||||
def test_previous_owner_pauses_after_another_device_claims():
|
||||
result = run_node(
|
||||
r"""
|
||||
@@ -403,3 +459,54 @@ const sync = createTodaySessionSync({
|
||||
assert result["calls"] == ["GET", "PATCH"]
|
||||
assert result["statuses"][-1] == "offline"
|
||||
assert "conflict" not in result["statuses"]
|
||||
|
||||
|
||||
def test_owner_publish_includes_every_timed_item():
|
||||
result = run_node(
|
||||
f"""
|
||||
const createTimer = require({json.dumps(str(TIMER))});
|
||||
const values = new Map();
|
||||
const storage = {{getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value)}};
|
||||
let now=0;
|
||||
const timer=createTimer({{storage,getLogin:()=> 'timmy',now:()=>now}});
|
||||
timer.activate('issue:r:1:'); now=60000;
|
||||
timer.activate('issue:r:2:'); now=150000;
|
||||
const bodies=[];
|
||||
const owned={{revision:1,device_id:'desktop-a',identity:'issue:r:2:',elapsed_ms:90000,running:true}};
|
||||
const sync=createTodaySessionSync({{
|
||||
getDeviceId:()=> 'desktop-a',timer,
|
||||
fetchJson:async (_url,options={{}})=>{{
|
||||
if (!options.method) return owned;
|
||||
const body=JSON.parse(options.body); bodies.push(body);
|
||||
return {{...body,revision:2,updated_at:1}};
|
||||
}},
|
||||
}});
|
||||
(async()=>{{
|
||||
await sync.refresh();
|
||||
await sync.publish();
|
||||
process.stdout.write(JSON.stringify(bodies[0].entries));
|
||||
}})().catch(error=>{{console.error(error);process.exit(1);}});
|
||||
"""
|
||||
)
|
||||
|
||||
assert result == [
|
||||
{"identity": "issue:r:1:", "elapsed_ms": 60_000},
|
||||
{"identity": "issue:r:2:", "elapsed_ms": 90_000},
|
||||
]
|
||||
|
||||
|
||||
def test_timer_snapshot_samples_active_elapsed_once_for_a_consistent_ledger():
|
||||
result = run_node(
|
||||
f"""
|
||||
const createTimer = require({json.dumps(str(TIMER))});
|
||||
const values=new Map();
|
||||
const storage={{getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value)}};
|
||||
let ticks=0;
|
||||
const timer=createTimer({{storage,getLogin:()=> 'timmy',now:()=>++ticks*1000}});
|
||||
timer.activate('issue:r:1:');
|
||||
const snapshot=timer.sessionSnapshot();
|
||||
process.stdout.write(JSON.stringify(snapshot));
|
||||
"""
|
||||
)
|
||||
|
||||
assert result["elapsed_ms"] == result["entries"][0]["elapsed_ms"]
|
||||
@@ -127,6 +127,9 @@ def test_active_session_is_durable_and_account_scoped(tmp_path):
|
||||
"device_id": "phone-a",
|
||||
"identity": "issue:stackchain/dashboard:42:",
|
||||
"elapsed_ms": 90_000,
|
||||
"entries": [{
|
||||
"identity": "issue:stackchain/dashboard:42:", "elapsed_ms": 90_000,
|
||||
}],
|
||||
"running": True,
|
||||
"break_deadline_at": None,
|
||||
"updated_at": 1234.5,
|
||||
@@ -137,12 +140,30 @@ def test_active_session_is_durable_and_account_scoped(tmp_path):
|
||||
"device_id": "",
|
||||
"identity": "",
|
||||
"elapsed_ms": 0,
|
||||
"entries": [],
|
||||
"running": False,
|
||||
"break_deadline_at": None,
|
||||
"updated_at": None,
|
||||
}
|
||||
|
||||
|
||||
def test_active_session_preserves_complete_bounded_timing_ledger(tmp_path):
|
||||
path = tmp_path / "today.sqlite3"
|
||||
store = TodayStore(path, clock=lambda: 1234.5)
|
||||
entries = [
|
||||
{"identity": "issue:r:1:", "elapsed_ms": 60_000},
|
||||
{"identity": "issue:r:2:", "elapsed_ms": 90_000},
|
||||
]
|
||||
|
||||
saved = store.update_session(
|
||||
"timmy", base_revision=0, device_id="phone-a",
|
||||
identity="issue:r:2:", elapsed_ms=90_000, running=True, entries=entries,
|
||||
)
|
||||
|
||||
assert saved["entries"] == entries
|
||||
assert TodayStore(path).get_session("timmy")["entries"] == entries
|
||||
|
||||
|
||||
def test_timed_break_deadline_is_durable_in_the_account_session(tmp_path):
|
||||
path = tmp_path / "today.sqlite3"
|
||||
store = TodayStore(path, clock=lambda: 1234.5)
|
||||
@@ -198,6 +219,45 @@ async def test_active_session_api_persists_a_bounded_break_deadline(monkeypatch,
|
||||
assert (await main.get_today_session()) == saved
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_active_session_api_persists_complete_timing_ledger(monkeypatch, tmp_path):
|
||||
async def user():
|
||||
return {"login": "timmy"}
|
||||
|
||||
store = TodayStore(tmp_path / "today.sqlite3")
|
||||
monkeypatch.setattr(main, "current_user", user)
|
||||
monkeypatch.setattr(main, "_today_store", lambda: store)
|
||||
entries = [
|
||||
{"identity": "issue:r:1:", "elapsed_ms": 5_000},
|
||||
{"identity": "issue:r:2:", "elapsed_ms": 8_000},
|
||||
]
|
||||
payload = main.TodaySessionUpdate(
|
||||
base_revision=0, device_id="phone-a", identity="issue:r:2:",
|
||||
elapsed_ms=8_000, running=True, entries=entries,
|
||||
)
|
||||
|
||||
saved = await main.update_today_session(payload)
|
||||
|
||||
assert saved["entries"] == entries
|
||||
assert (await main.get_today_session())["entries"] == entries
|
||||
|
||||
|
||||
def test_active_session_api_rejects_duplicate_or_inconsistent_ledger():
|
||||
base = {
|
||||
"base_revision": 0, "device_id": "phone-a", "identity": "issue:r:1:",
|
||||
"elapsed_ms": 5_000, "running": True,
|
||||
}
|
||||
with pytest.raises(ValueError, match="unique"):
|
||||
main.TodaySessionUpdate(**base, entries=[
|
||||
{"identity": "issue:r:1:", "elapsed_ms": 5_000},
|
||||
{"identity": "issue:r:1:", "elapsed_ms": 5_000},
|
||||
])
|
||||
with pytest.raises(ValueError, match="match"):
|
||||
main.TodaySessionUpdate(**base, entries=[
|
||||
{"identity": "issue:r:1:", "elapsed_ms": 4_999},
|
||||
])
|
||||
|
||||
|
||||
def test_active_session_api_rejects_a_running_break():
|
||||
with pytest.raises(ValueError, match="break must remain paused"):
|
||||
main.TodaySessionUpdate(
|
||||
|
||||
Reference in new issue
Block a user