feat: continue timed breaks across devices (Closes #1038)
This commit is contained in:
1 parent
1d242bab75
commit
c5b466f281
7 files changed
+172
-33
No files matched your search
@@ -37,7 +37,10 @@ process.stdout.write(JSON.stringify({started,paused,restored}));
|
||||
"deadline_at": 1012000,
|
||||
"expired": False,
|
||||
},
|
||||
"paused": {"identity": "issue:r:42:", "elapsed_ms": 12000, "running": False},
|
||||
"paused": {
|
||||
"identity": "issue:r:42:", "elapsed_ms": 12000, "running": False,
|
||||
"break_deadline_at": 1012000,
|
||||
},
|
||||
"restored": {
|
||||
"identity": "issue:r:42:",
|
||||
"deadline_at": 1012000,
|
||||
@@ -46,6 +49,22 @@ process.stdout.write(JSON.stringify({started,paused,restored}));
|
||||
}
|
||||
|
||||
|
||||
def test_timed_break_snapshot_carries_deadline_for_cross_device_sync():
|
||||
script = TIMER.read_text() + r"""
|
||||
const values = new Map();
|
||||
const storage = {getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value)};
|
||||
const timer = createTodayTimer({storage,getLogin:()=> 'timmy',now:()=>100000});
|
||||
timer.activate('issue:r:42:');
|
||||
timer.startBreak(15);
|
||||
process.stdout.write(JSON.stringify(timer.snapshot()));
|
||||
"""
|
||||
|
||||
assert run_node(script) == {
|
||||
"identity": "issue:r:42:", "elapsed_ms": 0, "running": False,
|
||||
"break_deadline_at": 1_000_000,
|
||||
}
|
||||
|
||||
|
||||
def test_break_expiry_never_restarts_time_and_resume_is_explicit_and_idempotent():
|
||||
script = TIMER.read_text() + r"""
|
||||
const values = new Map();
|
||||
|
||||
@@ -57,6 +57,56 @@ const sync = createTodaySessionSync({
|
||||
}
|
||||
|
||||
|
||||
def test_second_device_sees_and_resumes_a_timed_break():
|
||||
result = run_node(
|
||||
r"""
|
||||
const calls=[];
|
||||
const remote={revision:3,device_id:'phone-a',identity:'issue:r:42:',elapsed_ms:90000,running:false,break_deadline_at:1800000,updated_at:10};
|
||||
let adopted=null;
|
||||
const offers=[];
|
||||
const sync=createTodaySessionSync({
|
||||
getDeviceId:()=> 'desktop-b',
|
||||
fetchJson:async (url,options={})=>{
|
||||
calls.push(options.body ? JSON.parse(options.body) : null);
|
||||
return options.method === 'PATCH' ? {...remote,revision:4,device_id:'desktop-b',running:true,break_deadline_at:null} : remote;
|
||||
},
|
||||
timer:{adopt:(identity,elapsed,running)=>{adopted={identity,elapsed,running};}},
|
||||
onRemote:session=>offers.push(session),
|
||||
});
|
||||
(async()=>{
|
||||
await sync.refresh();
|
||||
const claimed=await sync.claim();
|
||||
process.stdout.write(JSON.stringify({offers,claimed,adopted,calls}));
|
||||
})().catch(error=>{console.error(error);process.exit(1);});
|
||||
"""
|
||||
)
|
||||
|
||||
assert result["offers"][0]["break_deadline_at"] == 1_800_000
|
||||
assert result["calls"][1] == {
|
||||
"base_revision": 3, "device_id": "desktop-b", "identity": "issue:r:42:",
|
||||
"elapsed_ms": 90_000, "running": True, "break_deadline_at": None,
|
||||
}
|
||||
assert result["adopted"] == {
|
||||
"identity": "issue:r:42:", "elapsed": 90_000, "running": True,
|
||||
}
|
||||
assert result["claimed"]["device_id"] == "desktop-b"
|
||||
|
||||
|
||||
def test_break_handoff_summary_is_actionable_and_privacy_safe():
|
||||
result = run_node(
|
||||
r"""
|
||||
const session={identity:'issue:private/repo:42:',elapsed_ms:90000,running:false,break_deadline_at:1800000};
|
||||
process.stdout.write(JSON.stringify({
|
||||
summary:todaySessionHandoffSummary(session,{title:'Secret issue'},0),
|
||||
}));
|
||||
"""
|
||||
)
|
||||
|
||||
assert result["summary"].startswith("On break until ")
|
||||
assert "Secret issue" not in result["summary"]
|
||||
assert "private/repo" not in result["summary"]
|
||||
|
||||
|
||||
def test_previous_owner_pauses_after_another_device_claims():
|
||||
result = run_node(
|
||||
r"""
|
||||
|
||||
@@ -22,6 +22,7 @@ def test_active_session_is_durable_and_account_scoped(tmp_path):
|
||||
"identity": "issue:stackchain/dashboard:42:",
|
||||
"elapsed_ms": 90_000,
|
||||
"running": True,
|
||||
"break_deadline_at": None,
|
||||
"updated_at": 1234.5,
|
||||
}
|
||||
assert TodayStore(path).get_session("timmy") == saved
|
||||
@@ -31,10 +32,26 @@ def test_active_session_is_durable_and_account_scoped(tmp_path):
|
||||
"identity": "",
|
||||
"elapsed_ms": 0,
|
||||
"running": False,
|
||||
"break_deadline_at": None,
|
||||
"updated_at": None,
|
||||
}
|
||||
|
||||
|
||||
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)
|
||||
|
||||
saved = store.update_session(
|
||||
"timmy", base_revision=0, device_id="phone-a",
|
||||
identity="issue:r:42:", elapsed_ms=90_000, running=False,
|
||||
break_deadline_at=1_800_000,
|
||||
)
|
||||
|
||||
assert saved["break_deadline_at"] == 1_800_000
|
||||
assert saved["running"] is False
|
||||
assert TodayStore(path).get_session("timmy") == saved
|
||||
|
||||
|
||||
def test_active_session_claim_rejects_a_stale_revision(tmp_path):
|
||||
store = TodayStore(tmp_path / "today.sqlite3")
|
||||
first = store.update_session(
|
||||
@@ -56,6 +73,25 @@ def test_active_session_claim_rejects_a_stale_revision(tmp_path):
|
||||
assert store.get_session("timmy") == claimed
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_active_session_api_persists_a_bounded_break_deadline(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)
|
||||
payload = main.TodaySessionUpdate(
|
||||
base_revision=0, device_id="phone-a", identity="issue:r:1:",
|
||||
elapsed_ms=5_000, running=False, break_deadline_at=1_800_000,
|
||||
)
|
||||
|
||||
saved = await main.update_today_session(payload)
|
||||
|
||||
assert saved["break_deadline_at"] == 1_800_000
|
||||
assert (await main.get_today_session()) == saved
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_active_session_api_returns_current_state_on_claim_conflict(monkeypatch, tmp_path):
|
||||
async def user():
|
||||
|
||||
Reference in new issue
Block a user