513 lines
17 KiB
Python
513 lines
17 KiB
Python
import json
|
|
import subprocess
|
|
from pathlib import Path
|
|
|
|
|
|
SOURCE = Path(__file__).parents[1] / "frontend" / "today-session-sync.js"
|
|
TIMER = Path(__file__).parents[1] / "frontend" / "today-timer.js"
|
|
|
|
|
|
def run_node(script: str) -> dict:
|
|
completed = subprocess.run(
|
|
["node", "-e", SOURCE.read_text() + "\n" + script],
|
|
check=True,
|
|
capture_output=True,
|
|
text=True,
|
|
)
|
|
return json.loads(completed.stdout)
|
|
|
|
|
|
def test_second_device_sees_and_claims_the_running_session():
|
|
result = run_node(
|
|
r"""
|
|
const calls = [];
|
|
const remote = {revision:3, device_id:'desktop-a', identity:'issue:r:42:', elapsed_ms:90000, running:true, updated_at:10};
|
|
let adopted = null;
|
|
const offers = [];
|
|
const sync = createTodaySessionSync({
|
|
getDeviceId:()=>'phone-b',
|
|
fetchJson:async (url, options={}) => {
|
|
calls.push({url, body:options.body ? JSON.parse(options.body) : null});
|
|
return options.method === 'PATCH' ? {...remote, revision:4, device_id:'phone-b'} : 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]["device_id"] == "desktop-a"
|
|
assert result["claimed"]["device_id"] == "phone-b"
|
|
assert result["adopted"] == {
|
|
"identity": "issue:r:42:", "elapsed": 90000, "running": True,
|
|
}
|
|
assert result["calls"][1] == {
|
|
"url": "api/v1/today/session",
|
|
"body": {
|
|
"base_revision": 3,
|
|
"device_id": "phone-b",
|
|
"identity": "issue:r:42:",
|
|
"elapsed_ms": 90000,
|
|
"running": True,
|
|
},
|
|
}
|
|
|
|
|
|
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"""
|
|
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_owner_refresh_recovers_a_server_confirmed_break_missing_from_device_state():
|
|
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}});
|
|
timer.adopt('issue:r:42:', 90000, false);
|
|
let rendered = 0;
|
|
const remote = {{
|
|
revision:3,device_id:'phone-a',identity:'issue:r:42:',elapsed_ms:90000,
|
|
running:false,break_deadline_at:400000,updated_at:10,
|
|
}};
|
|
const sync = createTodaySessionSync({{
|
|
getDeviceId:()=> 'phone-a',fetchJson:async()=>remote,timer,
|
|
onOwnedRestore:()=>{{rendered += 1;}},
|
|
}});
|
|
(async()=>{{
|
|
await sync.refresh();
|
|
process.stdout.write(JSON.stringify({{breakSnapshot:timer.breakSnapshot(),rendered}}));
|
|
}})().catch(error=>{{console.error(error);process.exit(1);}});
|
|
"""
|
|
)
|
|
|
|
assert result == {
|
|
"breakSnapshot": {
|
|
"identity": "issue:r:42:",
|
|
"deadline_at": 400_000,
|
|
"expired": False,
|
|
},
|
|
"rendered": 1,
|
|
}
|
|
|
|
|
|
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_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"""
|
|
let response = {revision:1, device_id:'desktop-a', identity:'issue:r:42:', elapsed_ms:1000, running:true};
|
|
let pauses = 0;
|
|
let transferred = null;
|
|
const sync = createTodaySessionSync({
|
|
getDeviceId:()=> 'desktop-a',
|
|
fetchJson:async()=>response,
|
|
timer:{pause:()=>{pauses += 1;}},
|
|
onTransferred:session=>{transferred=session;},
|
|
});
|
|
(async()=>{
|
|
await sync.refresh();
|
|
response = {...response, revision:2, device_id:'phone-b', elapsed_ms:2500};
|
|
await sync.refresh();
|
|
await sync.refresh();
|
|
process.stdout.write(JSON.stringify({pauses, transferred}));
|
|
})().catch(error=>{console.error(error);process.exit(1);});
|
|
"""
|
|
)
|
|
|
|
assert result["pauses"] == 1
|
|
assert result["transferred"]["device_id"] == "phone-b"
|
|
|
|
|
|
def test_owner_poll_publishes_current_elapsed_time():
|
|
result = run_node(
|
|
r"""
|
|
const calls = [];
|
|
let tick = null;
|
|
let elapsed = 1000;
|
|
const owned = {revision:1, device_id:'desktop-a', identity:'issue:r:42:', elapsed_ms:1000, running:true};
|
|
const sync = createTodaySessionSync({
|
|
getDeviceId:()=> 'desktop-a',
|
|
fetchJson:async (url, options={}) => {
|
|
calls.push({method:options.method || 'GET', body:options.body ? JSON.parse(options.body) : null});
|
|
return options.method === 'PATCH' ? {...owned, revision:2, elapsed_ms:elapsed} : owned;
|
|
},
|
|
timer:{snapshot:()=>({identity:owned.identity, elapsed_ms:elapsed, running:true})},
|
|
setInterval:callback=>{tick=callback; return 7;},
|
|
});
|
|
(async()=>{
|
|
await sync.refresh();
|
|
sync.start();
|
|
elapsed = 6500;
|
|
await tick();
|
|
process.stdout.write(JSON.stringify(calls));
|
|
})().catch(error=>{console.error(error);process.exit(1);});
|
|
"""
|
|
)
|
|
|
|
assert result[-1] == {
|
|
"method": "PATCH",
|
|
"body": {
|
|
"base_revision": 1,
|
|
"device_id": "desktop-a",
|
|
"identity": "issue:r:42:",
|
|
"elapsed_ms": 6500,
|
|
"running": True,
|
|
},
|
|
}
|
|
|
|
|
|
def test_publish_conflict_adopts_remote_owner_and_pauses_once():
|
|
result = run_node(
|
|
r"""
|
|
const calls = [];
|
|
const owned = {revision:2, device_id:'desktop-a', identity:'issue:r:42:', elapsed_ms:1000, running:true};
|
|
const remote = {...owned, revision:3, device_id:'phone-b', elapsed_ms:2500};
|
|
let pauses = 0;
|
|
const transfers = [];
|
|
const statuses = [];
|
|
let reads = 0;
|
|
const sync = createTodaySessionSync({
|
|
getDeviceId:()=> 'desktop-a',
|
|
fetchJson:async (url, options={}) => {
|
|
calls.push(options.method || 'GET');
|
|
if (options.method === 'PATCH') {
|
|
const error = new Error('session changed');
|
|
error.status = 409;
|
|
error.code = 'session_changed';
|
|
throw error;
|
|
}
|
|
reads += 1;
|
|
return reads === 1 ? owned : remote;
|
|
},
|
|
timer:{
|
|
snapshot:()=>({identity:owned.identity, elapsed_ms:1500, running:true}),
|
|
pause:()=>{pauses += 1;},
|
|
},
|
|
onTransferred:session=>transfers.push(session.device_id),
|
|
onStatus:status=>statuses.push(status),
|
|
});
|
|
(async()=>{
|
|
await sync.refresh();
|
|
await sync.publish();
|
|
await sync.refresh();
|
|
process.stdout.write(JSON.stringify({calls, pauses, transfers, statuses, session:sync.session()}));
|
|
})().catch(error=>{console.error(error);process.exit(1);});
|
|
"""
|
|
)
|
|
|
|
assert result["calls"] == ["GET", "PATCH", "GET", "GET"]
|
|
assert result["session"]["device_id"] == "phone-b"
|
|
assert result["pauses"] == 1
|
|
assert result["transfers"] == ["phone-b"]
|
|
assert "conflict" in result["statuses"]
|
|
|
|
|
|
def test_rapid_publishes_are_single_flight_and_coalesce_latest_snapshot():
|
|
result = run_node(
|
|
r"""
|
|
const owned = {revision:1, device_id:'desktop-a', identity:'issue:r:42:', elapsed_ms:0, running:true};
|
|
const bodies = [];
|
|
let active = 0;
|
|
let peak = 0;
|
|
let releaseFirst;
|
|
let patchCount = 0;
|
|
const firstPatch = new Promise(resolve=>{releaseFirst=resolve;});
|
|
const sync = createTodaySessionSync({
|
|
getDeviceId:()=> 'desktop-a',
|
|
fetchJson:async (url, options={}) => {
|
|
if (!options.method) return owned;
|
|
active += 1;
|
|
peak = Math.max(peak, active);
|
|
patchCount += 1;
|
|
const body = JSON.parse(options.body);
|
|
bodies.push(body);
|
|
if (patchCount === 1) await firstPatch;
|
|
active -= 1;
|
|
return {...owned, revision:1 + patchCount, elapsed_ms:body.elapsed_ms, running:body.running};
|
|
},
|
|
});
|
|
(async()=>{
|
|
await sync.refresh();
|
|
const first = sync.publish({identity:owned.identity, elapsed_ms:1000, running:true});
|
|
await Promise.resolve();
|
|
const middle = sync.publish({identity:owned.identity, elapsed_ms:2000, running:false});
|
|
const latest = sync.publish({identity:owned.identity, elapsed_ms:3000, running:true});
|
|
releaseFirst();
|
|
await Promise.all([first, middle, latest]);
|
|
process.stdout.write(JSON.stringify({peak, bodies, session:sync.session()}));
|
|
})().catch(error=>{console.error(error);process.exit(1);});
|
|
"""
|
|
)
|
|
|
|
assert result["peak"] == 1
|
|
assert [body["elapsed_ms"] for body in result["bodies"]] == [1000, 3000]
|
|
assert result["session"]["elapsed_ms"] == 3000
|
|
|
|
|
|
def test_remote_conflict_discards_queued_local_publishes_until_explicit_claim():
|
|
result = run_node(
|
|
r"""
|
|
const owned = {revision:2, device_id:'desktop-a', identity:'issue:r:42:', elapsed_ms:1000, running:true};
|
|
const remote = {...owned, revision:3, device_id:'phone-b', elapsed_ms:2500};
|
|
const calls = [];
|
|
let reads = 0;
|
|
let releaseConflict;
|
|
const conflictReady = new Promise(resolve=>{releaseConflict=resolve;});
|
|
const sync = createTodaySessionSync({
|
|
getDeviceId:()=> 'desktop-a',
|
|
fetchJson:async (url, options={}) => {
|
|
calls.push(options.method || 'GET');
|
|
if (options.method === 'PATCH') {
|
|
await conflictReady;
|
|
const error = new Error('session changed');
|
|
error.status = 409;
|
|
throw error;
|
|
}
|
|
reads += 1;
|
|
return reads === 1 ? owned : remote;
|
|
},
|
|
timer:{pause:()=>{}},
|
|
});
|
|
(async()=>{
|
|
await sync.refresh();
|
|
const stale = sync.publish({identity:owned.identity, elapsed_ms:1500, running:true});
|
|
await Promise.resolve();
|
|
const queued = sync.publish({identity:owned.identity, elapsed_ms:2000, running:true});
|
|
releaseConflict();
|
|
await Promise.all([stale, queued]);
|
|
process.stdout.write(JSON.stringify({calls, session:sync.session()}));
|
|
})().catch(error=>{console.error(error);process.exit(1);});
|
|
"""
|
|
)
|
|
|
|
assert result["calls"] == ["GET", "PATCH", "GET"]
|
|
assert result["session"]["device_id"] == "phone-b"
|
|
|
|
|
|
def test_transient_failure_retains_latest_intent_for_retry():
|
|
result = run_node(
|
|
r"""
|
|
const owned = {revision:4, device_id:'desktop-a', identity:'issue:r:42:', elapsed_ms:1000, running:true};
|
|
const bodies = [];
|
|
const statuses = [];
|
|
let attempts = 0;
|
|
const sync = createTodaySessionSync({
|
|
getDeviceId:()=> 'desktop-a',
|
|
fetchJson:async (url, options={}) => {
|
|
if (!options.method) return owned;
|
|
attempts += 1;
|
|
bodies.push(JSON.parse(options.body));
|
|
if (attempts === 1) {
|
|
const error = new Error('temporarily unavailable');
|
|
error.status = 503;
|
|
throw error;
|
|
}
|
|
return {...owned, revision:5, elapsed_ms:bodies.at(-1).elapsed_ms};
|
|
},
|
|
onStatus:status=>statuses.push(status),
|
|
});
|
|
(async()=>{
|
|
await sync.refresh();
|
|
const first = await sync.publish({identity:owned.identity, elapsed_ms:4500, running:true});
|
|
const retried = await sync.publish();
|
|
process.stdout.write(JSON.stringify({first, retried, bodies, statuses}));
|
|
})().catch(error=>{console.error(error);process.exit(1);});
|
|
"""
|
|
)
|
|
|
|
assert result["first"] is None
|
|
assert [body["elapsed_ms"] for body in result["bodies"]] == [4500, 4500]
|
|
assert result["retried"]["revision"] == 5
|
|
assert "offline" in result["statuses"]
|
|
|
|
|
|
def test_claim_transport_failure_stays_retryable_without_false_conflict():
|
|
result = run_node(
|
|
r"""
|
|
const remote = {revision:7, device_id:'phone-b', identity:'issue:r:42:', elapsed_ms:9000, running:true};
|
|
const calls = [];
|
|
const statuses = [];
|
|
const sync = createTodaySessionSync({
|
|
getDeviceId:()=> 'desktop-a',
|
|
fetchJson:async (url, options={}) => {
|
|
calls.push(options.method || 'GET');
|
|
if (!options.method) return remote;
|
|
const error = new Error('gateway unavailable');
|
|
error.status = 503;
|
|
throw error;
|
|
},
|
|
onStatus:status=>statuses.push(status),
|
|
});
|
|
(async()=>{
|
|
await sync.refresh();
|
|
const claimed = await sync.claim();
|
|
process.stdout.write(JSON.stringify({claimed, calls, statuses}));
|
|
})().catch(error=>{console.error(error);process.exit(1);});
|
|
"""
|
|
)
|
|
|
|
assert result["claimed"] is None
|
|
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"]
|