feat: preserve offline Today recap drafts (Closes #581)
This commit is contained in:
1 parent
cab230e3a2
commit
6a3c6c7a9e
4 files changed
+185
-7
No files matched your search
@@ -119,6 +119,125 @@ recap.save().then(saved=>process.stdout.write(JSON.stringify({{
|
||||
assert output["cleared"] == 1
|
||||
|
||||
|
||||
def test_recap_controller_restores_corrected_draft_with_stable_retry_identity():
|
||||
script = f"""
|
||||
const createRecap = require({json.dumps(str(TODAY_RECAP))});
|
||||
const values = new Map();
|
||||
const storage = {{
|
||||
getItem:key => values.has(key) ? values.get(key) : null,
|
||||
setItem:(key,value) => values.set(key,value),
|
||||
removeItem:key => values.delete(key),
|
||||
}};
|
||||
const options = {{
|
||||
save:payload => Promise.resolve(payload), clear:() => {{}}, storage,
|
||||
getLogin:() => ' Timmy ', makeId:() => 'stable-session',
|
||||
}};
|
||||
const first = createRecap(options);
|
||||
first.begin([{{identity:'issue:r:1:',elapsed_ms:12*60000}}], {{'issue:r:1:':20}});
|
||||
first.correct('issue:r:1:', 17);
|
||||
const restored = createRecap({{...options, makeId:() => 'different-session'}}).snapshot();
|
||||
process.stdout.write(JSON.stringify({{restored, keys:[...values.keys()]}}));
|
||||
"""
|
||||
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||
assert result.returncode == 0, result.stderr
|
||||
output = json.loads(result.stdout)
|
||||
assert output["restored"] == {
|
||||
"session_id": "stable-session",
|
||||
"items": [{"identity": "issue:r:1:", "estimate_minutes": 20, "actual_minutes": 17}],
|
||||
"estimated_minutes": 20,
|
||||
"actual_minutes": 17,
|
||||
"variance_minutes": -3,
|
||||
}
|
||||
assert output["keys"] == ["stackchain.today-recap-draft.v1.timmy"]
|
||||
|
||||
|
||||
def test_recap_controller_removes_corrupt_persisted_draft():
|
||||
script = f"""
|
||||
const createRecap = require({json.dumps(str(TODAY_RECAP))});
|
||||
const key='stackchain.today-recap-draft.v1.timmy';
|
||||
const values=new Map([[key, '{{not-json']]);
|
||||
const storage={{
|
||||
getItem:key => values.has(key) ? values.get(key) : null,
|
||||
setItem:(key,value) => values.set(key,value),
|
||||
removeItem:key => values.delete(key),
|
||||
}};
|
||||
const recap=createRecap({{save:()=>Promise.resolve(),clear:()=>{{}},storage,getLogin:()=> 'timmy'}});
|
||||
process.stdout.write(JSON.stringify({{draft:recap.snapshot(),hasKey:values.has(key)}}));
|
||||
"""
|
||||
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert json.loads(result.stdout) == {"draft": None, "hasKey": False}
|
||||
|
||||
|
||||
def test_recap_controller_restores_after_account_confirmation():
|
||||
script = f"""
|
||||
const createRecap = require({json.dumps(str(TODAY_RECAP))});
|
||||
const values=new Map([['stackchain.today-recap-draft.v1.timmy', JSON.stringify({{
|
||||
session_id:'offline-session', items:[{{identity:'issue:r:2:',estimate_minutes:null,actual_minutes:9}}]
|
||||
}})]]);
|
||||
const storage={{getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)}};
|
||||
let login='';
|
||||
const recap=createRecap({{save:()=>Promise.resolve(),clear:()=>{{}},storage,getLogin:()=>login}});
|
||||
const before=recap.snapshot(); login='timmy'; const didRestore=recap.restore(); const after=recap.snapshot();
|
||||
process.stdout.write(JSON.stringify({{before,didRestore,after}}));
|
||||
"""
|
||||
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert json.loads(result.stdout) == {
|
||||
"before": None,
|
||||
"didRestore": True,
|
||||
"after": {
|
||||
"session_id": "offline-session",
|
||||
"items": [{"identity": "issue:r:2:", "estimate_minutes": None, "actual_minutes": 9}],
|
||||
"estimated_minutes": 0,
|
||||
"actual_minutes": 9,
|
||||
"variance_minutes": 9,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def test_recap_controller_never_exposes_draft_after_account_switch():
|
||||
script = f"""
|
||||
const createRecap = require({json.dumps(str(TODAY_RECAP))});
|
||||
const values=new Map();
|
||||
const storage={{getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)}};
|
||||
let login='timmy';
|
||||
const recap=createRecap({{save:()=>Promise.resolve(),clear:()=>{{}},storage,getLogin:()=>login,makeId:()=> 'timmy-session'}});
|
||||
recap.begin([{{identity:'issue:r:1:',elapsed_ms:60000}}]);
|
||||
login='alexander';
|
||||
process.stdout.write(JSON.stringify({{visible:recap.snapshot(),timmyDraft:values.has('stackchain.today-recap-draft.v1.timmy')}}));
|
||||
"""
|
||||
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert json.loads(result.stdout) == {"visible": None, "timmyDraft": True}
|
||||
|
||||
|
||||
def test_recap_controller_keeps_failed_save_and_clears_only_after_confirmation():
|
||||
script = f"""
|
||||
const createRecap = require({json.dumps(str(TODAY_RECAP))});
|
||||
const key='stackchain.today-recap-draft.v1.timmy'; const values=new Map(); let cleared=0; let fail=true;
|
||||
const storage={{getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)}};
|
||||
const recap=createRecap({{
|
||||
save:payload => fail ? Promise.reject(new Error('offline')) : Promise.resolve(payload),
|
||||
clear:()=>{{cleared += 1;}}, storage, getLogin:()=> 'timmy', makeId:()=> 'retry-session'
|
||||
}});
|
||||
recap.begin([{{identity:'issue:r:1:',elapsed_ms:60000}}]);
|
||||
(async()=>{{
|
||||
try {{ await recap.save(); }} catch (_error) {{}}
|
||||
const afterFailure={{draft:recap.snapshot(),stored:values.has(key),cleared}};
|
||||
fail=false; await recap.save();
|
||||
process.stdout.write(JSON.stringify({{afterFailure,afterSuccess:{{draft:recap.snapshot(),stored:values.has(key),cleared}}}}));
|
||||
}})().catch(error=>{{console.error(error);process.exit(1);}});
|
||||
"""
|
||||
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||
assert result.returncode == 0, result.stderr
|
||||
output = json.loads(result.stdout)
|
||||
assert output["afterFailure"]["draft"]["session_id"] == "retry-session"
|
||||
assert output["afterFailure"]["stored"] is True
|
||||
assert output["afterFailure"]["cleared"] == 0
|
||||
assert output["afterSuccess"] == {"draft": None, "stored": False, "cleared": 1}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_dashboard_renders_mobile_today_recap_flow():
|
||||
html = main.FRONTEND_BUILD.dashboard_html
|
||||
@@ -134,6 +253,8 @@ async def test_dashboard_renders_mobile_today_recap_flow():
|
||||
assert "isActive: () => workSession.checkpointed()" in dashboard
|
||||
assert "openTodayRecapAfterSession(view" in recap_source
|
||||
assert "api/v1/today/recaps" in recap_source
|
||||
assert "storage:localStorage" in recap_source
|
||||
assert "() => planningOwnerLogin" in dashboard
|
||||
assert "recapEntries()" in timer and "clearRecap()" in timer
|
||||
assert ".today-recap-header button { min-height:44px;" in css
|
||||
assert ".today-recap-actions button { min-height:44px;" in css
|
||||
|
||||
Reference in new issue
Block a user