409 lines
18 KiB
Python
409 lines
18 KiB
Python
import json
|
|
import subprocess
|
|
from pathlib import Path
|
|
|
|
import httpx
|
|
import pytest
|
|
|
|
from src import main
|
|
from src.today_store import TodayStore
|
|
|
|
|
|
ROOT = Path(__file__).parents[1]
|
|
TODAY_RECAP = ROOT / "frontend" / "today-recap.js"
|
|
|
|
|
|
def test_recap_store_is_idempotent_account_scoped_bounded_and_newest_first(tmp_path):
|
|
now = [1_000.0]
|
|
store = TodayStore(tmp_path / "today.sqlite3", recap_limit=2, clock=lambda: now[0])
|
|
first = {
|
|
"session_id": "session-1",
|
|
"items": [
|
|
{"identity": "issue:stackchain/dashboard:579:", "estimate_minutes": 30, "actual_minutes": 42},
|
|
{"identity": "pull:stackchain/api:8:", "estimate_minutes": None, "actual_minutes": 12},
|
|
],
|
|
}
|
|
|
|
saved = store.save_recap("Timmy", first["session_id"], first["items"])
|
|
replay = store.save_recap("timmy", first["session_id"], first["items"])
|
|
now[0] += 1
|
|
store.save_recap("timmy", "session-2", [{"identity": "issue:r:2:", "estimate_minutes": 10, "actual_minutes": 8}])
|
|
now[0] += 1
|
|
store.save_recap("timmy", "session-3", [{"identity": "issue:r:3:", "estimate_minutes": 10, "actual_minutes": 15}])
|
|
store.save_recap("alexander", "other", [{"identity": "issue:r:9:", "estimate_minutes": 5, "actual_minutes": 5}])
|
|
|
|
assert saved == replay
|
|
assert saved["session_id"] == "session-1"
|
|
assert saved["estimated_minutes"] == 30
|
|
assert saved["actual_minutes"] == 54
|
|
assert [row["session_id"] for row in store.list_recaps("timmy")] == ["session-3", "session-2"]
|
|
assert [row["session_id"] for row in store.list_recaps("alexander")] == ["other"]
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"items,message",
|
|
[
|
|
([], "at least one"),
|
|
([{"identity": "issue:r:1:", "estimate_minutes": 5, "actual_minutes": -1}], "actual"),
|
|
([{"identity": "issue:r:1:", "estimate_minutes": 5, "actual_minutes": 1441}], "actual"),
|
|
([{"identity": "", "estimate_minutes": 5, "actual_minutes": 1}], "identity"),
|
|
],
|
|
)
|
|
def test_recap_store_rejects_invalid_or_unbounded_rows(tmp_path, items, message):
|
|
store = TodayStore(tmp_path / "today.sqlite3")
|
|
with pytest.raises(ValueError, match=message):
|
|
store.save_recap("timmy", "session", items)
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_authenticated_recap_api_is_no_store_idempotent_and_account_scoped(monkeypatch, tmp_path):
|
|
monkeypatch.setenv("STACKCHAIN_DASHBOARD_AUTH_MODE", "operator")
|
|
monkeypatch.setenv("STACKCHAIN_DASHBOARD_ACCESS_TOKEN", "correct horse battery staple")
|
|
monkeypatch.setenv("STACKCHAIN_DASHBOARD_SESSION_SECRET", "a-separate-session-signing-secret-with-enough-entropy")
|
|
monkeypatch.setenv("STACKCHAIN_SESSION_DB", str(tmp_path / "sessions.sqlite3"))
|
|
monkeypatch.setenv("STACKCHAIN_LOGIN_ATTEMPT_DB", str(tmp_path / "login.sqlite3"))
|
|
monkeypatch.setenv("STACKCHAIN_TODAY_DB", str(tmp_path / "today.sqlite3"))
|
|
|
|
async def user():
|
|
return {"id": 1, "login": "Timmy"}
|
|
|
|
monkeypatch.setattr(main, "current_user", user)
|
|
transport = httpx.ASGITransport(app=main.app)
|
|
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
|
|
await client.post("/api/v1/session", json={"access_token": "correct horse battery staple"})
|
|
headers = {"Origin": "https://test", "X-CSRF-Token": client.cookies["stackchain_csrf"]}
|
|
payload = {"session_id": "mobile-session-1", "items": [
|
|
{"identity": "issue:stackchain/dashboard:579:", "estimate_minutes": 30, "actual_minutes": 42}
|
|
]}
|
|
created = await client.post("/api/v1/today/recaps", json=payload, headers=headers)
|
|
replay = await client.post("/api/v1/today/recaps", json=payload, headers=headers)
|
|
history = await client.get("/api/v1/today/recaps")
|
|
|
|
assert created.status_code == replay.status_code == 200
|
|
assert created.json() == replay.json()
|
|
assert history.json()["recaps"] == [created.json()]
|
|
assert created.headers["cache-control"] == history.headers["cache-control"] == "no-store"
|
|
|
|
|
|
def test_recap_controller_calculates_variance_validates_corrections_and_clears_after_save():
|
|
script = f"""
|
|
const createRecap = require({json.dumps(str(TODAY_RECAP))});
|
|
const calls=[]; let cleared=0;
|
|
const recap=createRecap({{
|
|
save:payload=>{{calls.push(payload); return Promise.resolve(payload);}},
|
|
clear:()=>{{cleared += 1;}},
|
|
makeId:()=> 'session-fixed',
|
|
}});
|
|
const draft=recap.begin([
|
|
{{identity:'issue:r:1:',elapsed_ms:42*60000}},
|
|
{{identity:'pull:r:2:',elapsed_ms:12*60000}},
|
|
], {{'issue:r:1:':30}});
|
|
const invalidLow=recap.correct('issue:r:1:',-1);
|
|
const invalidHigh=recap.correct('issue:r:1:',1441);
|
|
const corrected=recap.correct('issue:r:1:',45);
|
|
recap.save().then(saved=>process.stdout.write(JSON.stringify({{
|
|
draft,invalidLow,invalidHigh,corrected,saved,calls,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["draft"]["estimated_minutes"] == 30
|
|
assert output["draft"]["actual_minutes"] == 54
|
|
assert output["draft"]["variance_minutes"] == 24
|
|
assert output["invalidLow"] is False
|
|
assert output["invalidHigh"] is False
|
|
assert output["corrected"] is True
|
|
assert output["calls"][0]["session_id"] == "session-fixed"
|
|
assert output["calls"][0]["items"][0]["actual_minutes"] == 45
|
|
assert output["cleared"] == 1
|
|
|
|
|
|
def test_recap_feedback_uses_work_metadata_and_reports_per_item_variance():
|
|
script = f"""
|
|
const createRecap = require({json.dumps(str(TODAY_RECAP))});
|
|
const rows=createRecap.feedbackRows({{
|
|
items:[
|
|
{{identity:'issue:stackchain/dashboard:583:',estimate_minutes:30,actual_minutes:52}},
|
|
{{identity:'pull:stackchain/api:9:',estimate_minutes:null,actual_minutes:12}},
|
|
]
|
|
}}, identity => identity.startsWith('issue:') ? {{
|
|
title:'Turn recap feedback into a plan', key:'stackchain/dashboard#583'
|
|
}} : null);
|
|
process.stdout.write(JSON.stringify(rows));
|
|
"""
|
|
run = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
|
assert run.returncode == 0, run.stderr
|
|
assert json.loads(run.stdout) == [
|
|
{
|
|
"identity": "issue:stackchain/dashboard:583:",
|
|
"label": "Turn recap feedback into a plan",
|
|
"context": "stackchain/dashboard#583",
|
|
"estimate_minutes": 30,
|
|
"actual_minutes": 52,
|
|
"variance_minutes": 22,
|
|
},
|
|
{
|
|
"identity": "pull:stackchain/api:9:",
|
|
"label": "pull:stackchain/api:9:",
|
|
"context": "Work details unavailable",
|
|
"estimate_minutes": None,
|
|
"actual_minutes": 12,
|
|
"variance_minutes": None,
|
|
},
|
|
]
|
|
|
|
|
|
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}
|
|
|
|
|
|
def test_recap_controller_returns_actuals_for_replanning_only_after_save_confirmation():
|
|
script = f"""
|
|
const createRecap = require({json.dumps(str(TODAY_RECAP))});
|
|
let fail=true;
|
|
const recap=createRecap({{
|
|
save:payload => fail ? Promise.reject(new Error('offline')) : Promise.resolve({{saved:true}}),
|
|
clear:()=>{{}}, makeId:()=> 'replan-session'
|
|
}});
|
|
recap.begin([
|
|
{{identity:'issue:r:1:',elapsed_ms:52*60000}},
|
|
{{identity:'issue:r:2:',elapsed_ms:7*60000}},
|
|
], {{'issue:r:1:':30,'issue:r:2:':7}});
|
|
(async()=>{{
|
|
let failed;
|
|
try {{ await recap.saveForReplan(); }} catch (error) {{ failed=error.message; }}
|
|
const retained=recap.snapshot();
|
|
fail=false;
|
|
const confirmed=await recap.saveForReplan();
|
|
process.stdout.write(JSON.stringify({{failed,retained,confirmed,after:recap.snapshot()}}));
|
|
}})().catch(error=>{{console.error(error);process.exit(1);}});
|
|
"""
|
|
run = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
|
assert run.returncode == 0, run.stderr
|
|
output = json.loads(run.stdout)
|
|
assert output["failed"] == "offline"
|
|
assert output["retained"]["session_id"] == "replan-session"
|
|
assert output["confirmed"] == {
|
|
"result": {"saved": True},
|
|
"actual_minutes": {"issue:r:1:": 52, "issue:r:2:": 7},
|
|
}
|
|
assert output["after"] is None
|
|
|
|
|
|
def test_recap_controller_restores_account_scoped_pending_replan_until_completed():
|
|
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 options={{save:payload=>Promise.resolve({{session_id:payload.session_id}}),clear:()=>{{}},storage,getLogin:()=>login}};
|
|
const first=createRecap({{...options,makeId:()=> 'saved-session'}});
|
|
first.begin([{{identity:'issue:r:1:',elapsed_ms:52*60000}}], {{'issue:r:1:':30}});
|
|
(async()=>{{
|
|
await first.saveForReplan();
|
|
const afterSave=first.pendingReplan();
|
|
const restored=createRecap(options).pendingReplan();
|
|
login='alexander';
|
|
const otherAccount=createRecap(options).pendingReplan();
|
|
login='timmy';
|
|
const retainedAfterCancel=createRecap(options).pendingReplan();
|
|
createRecap(options).completeReplan();
|
|
const afterComplete=createRecap(options).pendingReplan();
|
|
process.stdout.write(JSON.stringify({{afterSave,restored,otherAccount,retainedAfterCancel,afterComplete,keys:[...values.keys()]}}));
|
|
}})().catch(error=>{{console.error(error);process.exit(1);}});
|
|
"""
|
|
run = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
|
assert run.returncode == 0, run.stderr
|
|
output = json.loads(run.stdout)
|
|
pending = {
|
|
"session_id": "saved-session",
|
|
"actual_minutes": {"issue:r:1:": 52},
|
|
}
|
|
assert output == {
|
|
"afterSave": pending,
|
|
"restored": pending,
|
|
"otherAccount": None,
|
|
"retainedAfterCancel": pending,
|
|
"afterComplete": None,
|
|
"keys": [],
|
|
}
|
|
|
|
|
|
def test_recap_controller_removes_malformed_or_unbounded_pending_replan():
|
|
script = f"""
|
|
const createRecap = require({json.dumps(str(TODAY_RECAP))});
|
|
const key='stackchain.today-recap-handoff.v1.timmy';
|
|
const values=new Map([[key, JSON.stringify({{session_id:'saved',actual_minutes:Object.fromEntries(
|
|
Array.from({{length:21}}, (_,index)=>['issue:r:' + index + ':', 10])
|
|
)}})]]);
|
|
const storage={{getItem: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({{pending:recap.pendingReplan(),hasKey:values.has(key)}}));
|
|
"""
|
|
run = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
|
assert run.returncode == 0, run.stderr
|
|
assert json.loads(run.stdout) == {"pending": None, "hasKey": False}
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_dashboard_renders_mobile_today_recap_flow():
|
|
html = main.FRONTEND_BUILD.dashboard_html
|
|
css = (ROOT / "frontend" / "dashboard.css").read_text()
|
|
timer = (ROOT / "frontend" / "today-timer.js").read_text()
|
|
recap_source = TODAY_RECAP.read_text()
|
|
dashboard = (ROOT / "frontend" / "dashboard.js").read_text()
|
|
|
|
assert 'id="today-recap-sheet" role="dialog"' in html
|
|
assert 'id="today-recap-history"' in html
|
|
assert 'static/today-recap.js' in main.FRONTEND_BUILD.page_sources
|
|
assert "timer.recapEntries()" in recap_source
|
|
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
|
|
assert "overflow-x:hidden" in css
|
|
assert "Save recap & adjust plan" in html
|
|
assert "todayRecapFeedbackRows(draft, describeWork)" in recap_source
|
|
assert 'class="today-recap-variance"' in recap_source
|
|
assert "await recap.saveForReplan()" in recap_source
|
|
assert "adjustPlan(handoff.actual_minutes)" in recap_source
|
|
assert "identity => [...todayMyWork, ...activeMyWork].find" in dashboard
|
|
assert "openPlanToday(qs('#plan-today'), true, actualMinutes)" in dashboard
|
|
assert ".today-recap-row { grid-template-columns:1fr; }" in css
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_dashboard_resumes_and_explicitly_discards_pending_recap_feedback():
|
|
html = main.FRONTEND_BUILD.dashboard_html
|
|
dashboard = (ROOT / "frontend" / "dashboard.js").read_text()
|
|
recap_source = TODAY_RECAP.read_text()
|
|
|
|
assert 'id="discard-recap-replan"' in html
|
|
assert "todayRecapView.pendingReplan()?.actual_minutes" in dashboard
|
|
assert "todayRecapView.completeReplan();" in dashboard
|
|
assert "todayRecapView.discardReplan();" in dashboard
|
|
assert "if (capacityAware && !todayWork.replacePlanning(plan)) return false;" in dashboard
|
|
assert dashboard.index("todayWork.replacePlanning(plan)") < dashboard.index("todayRecapView.completeReplan();")
|
|
assert "pendingReplan:recap.pendingReplan" in recap_source
|
|
assert "discardReplan:recap.discardReplan" in recap_source
|