feat: resume interrupted Today sessions (#395)
This commit is contained in:
1 parent
ff0e47cbe7
commit
1f7fd093fd
13 files changed
+305
-14
No files matched your search
@@ -347,5 +347,5 @@ async def test_dashboard_syncs_every_later_change_and_exposes_account_status():
|
||||
def test_later_sync_ships_atomically_in_the_offline_shell():
|
||||
source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v59" in source
|
||||
assert "stackchain-dashboard-shell-v60" in source
|
||||
assert "BASE + 'static/later-sync.js'" in source
|
||||
@@ -137,4 +137,4 @@ def test_markdown_work_bodies_are_mobile_safe_block_containers():
|
||||
assert ".markdown-content { min-width:0; max-width:100%; overflow-wrap:anywhere;" in css
|
||||
assert ".markdown-content pre { max-width:100%; overflow-x:auto;" in css
|
||||
assert ".markdown-content a { min-height:44px;" in css
|
||||
assert "stackchain-dashboard-shell-v59" in worker
|
||||
assert "stackchain-dashboard-shell-v60" in worker
|
||||
@@ -35,4 +35,4 @@ def test_offline_shell_contains_every_local_dashboard_runtime_asset():
|
||||
shell_assets = set(re.findall(r"BASE \+ '([^']+)'", worker.split("async function sessionCsrf", 1)[0]))
|
||||
|
||||
assert local_assets <= shell_assets, f"Offline shell is missing: {sorted(local_assets - shell_assets)}"
|
||||
assert "stackchain-dashboard-shell-v59" in worker
|
||||
assert "stackchain-dashboard-shell-v60" in worker
|
||||
@@ -1428,6 +1428,157 @@ process.stdout.write(JSON.stringify({{finished,active:session.active()}}));
|
||||
assert json.loads(result.stdout) == {"finished": 1, "active": False}
|
||||
|
||||
|
||||
def test_today_session_checkpoint_resumes_current_item_after_reload():
|
||||
script = f"""
|
||||
const buildMyWork = require({json.dumps(str(MY_WORK))});
|
||||
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 items = [1, 2, 3].map(number => ({{
|
||||
kind:'issue', repository:'stackchain/dashboard', number, title:'Item ' + number,
|
||||
}}));
|
||||
const opened = [];
|
||||
const firstCheckpoint = buildMyWork.createWorkSessionCheckpoint({{
|
||||
storage, getLogin: () => 'timmy',
|
||||
}});
|
||||
const first = buildMyWork.createWorkSession({{
|
||||
getItems: () => items, getFilter: () => 'all',
|
||||
checkpoint: firstCheckpoint,
|
||||
onOpen: item => opened.push('first:' + item.number),
|
||||
onProgress: () => {{}}, onFinish: () => {{}},
|
||||
}});
|
||||
first.start();
|
||||
first.next();
|
||||
|
||||
const secondCheckpoint = buildMyWork.createWorkSessionCheckpoint({{
|
||||
storage, getLogin: () => 'timmy',
|
||||
}});
|
||||
const resumed = buildMyWork.createWorkSession({{
|
||||
getItems: () => items, getFilter: () => 'all',
|
||||
checkpoint: secondCheckpoint,
|
||||
onOpen: item => opened.push('resumed:' + item.number),
|
||||
onProgress: () => {{}}, onFinish: () => {{}},
|
||||
}});
|
||||
const available = resumed.resumable();
|
||||
const didResume = resumed.resume();
|
||||
process.stdout.write(JSON.stringify({{
|
||||
available, didResume, opened, checkpoint:JSON.parse(values.values().next().value),
|
||||
}}));
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["node", "-e", script], check=True, capture_output=True, text=True
|
||||
)
|
||||
|
||||
assert json.loads(result.stdout) == {
|
||||
"available": True,
|
||||
"didResume": True,
|
||||
"opened": ["first:1", "first:2", "resumed:2"],
|
||||
"checkpoint": {
|
||||
"version": 1,
|
||||
"login": "timmy",
|
||||
"identity": "issue:stackchain/dashboard:2:",
|
||||
"index": 1,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def test_today_session_checkpoint_is_account_bound_and_end_clears_it():
|
||||
script = f"""
|
||||
const buildMyWork = require({json.dumps(str(MY_WORK))});
|
||||
let login = 'timmy';
|
||||
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 checkpoint = buildMyWork.createWorkSessionCheckpoint({{storage, getLogin:() => login}});
|
||||
const session = buildMyWork.createWorkSession({{
|
||||
getItems: () => [{{kind:'issue',repository:'stackchain/dashboard',number:7}}],
|
||||
getFilter: () => 'all', checkpoint,
|
||||
onOpen: () => {{}}, onProgress: () => {{}}, onFinish: () => {{}},
|
||||
}});
|
||||
session.start();
|
||||
login = 'alexander';
|
||||
const visibleToOtherAccount = session.resumable();
|
||||
const retainedForOwner = values.size;
|
||||
login = 'timmy';
|
||||
session.end();
|
||||
process.stdout.write(JSON.stringify({{
|
||||
visibleToOtherAccount, retainedForOwner, active:session.active(), stored:values.size,
|
||||
}}));
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["node", "-e", script], check=True, capture_output=True, text=True
|
||||
)
|
||||
|
||||
assert json.loads(result.stdout) == {
|
||||
"visibleToOtherAccount": False,
|
||||
"retainedForOwner": 1,
|
||||
"active": False,
|
||||
"stored": 0,
|
||||
}
|
||||
|
||||
|
||||
def test_resuming_an_empty_today_plan_clears_the_stale_checkpoint():
|
||||
script = f"""
|
||||
const buildMyWork = require({json.dumps(str(MY_WORK))});
|
||||
const values = new Map([['stackchain.today-session.v1', JSON.stringify({{
|
||||
version:1, login:'timmy', identity:'issue:stackchain/dashboard:7:', index:0,
|
||||
}})]]);
|
||||
const storage = {{
|
||||
getItem:key => values.get(key) || null,
|
||||
setItem:(key,value) => values.set(key,value),
|
||||
removeItem:key => values.delete(key),
|
||||
}};
|
||||
const checkpoint = buildMyWork.createWorkSessionCheckpoint({{storage,getLogin:() => 'timmy'}});
|
||||
const session = buildMyWork.createWorkSession({{
|
||||
getItems:() => [], getFilter:() => 'all', checkpoint,
|
||||
onOpen:() => {{}}, onProgress:() => {{}}, onFinish:() => {{}},
|
||||
}});
|
||||
const resumed = session.resume();
|
||||
process.stdout.write(JSON.stringify({{resumed, stored:values.size, available:session.resumable()}}));
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["node", "-e", script], check=True, capture_output=True, text=True
|
||||
)
|
||||
|
||||
assert json.loads(result.stdout) == {"resumed": False, "stored": 0, "available": False}
|
||||
|
||||
|
||||
def test_today_session_keeps_working_when_checkpoint_storage_fails():
|
||||
script = f"""
|
||||
const buildMyWork = require({json.dumps(str(MY_WORK))});
|
||||
let errors = 0;
|
||||
const opened = [];
|
||||
const checkpoint = buildMyWork.createWorkSessionCheckpoint({{
|
||||
storage:{{
|
||||
getItem:() => null,
|
||||
setItem:() => {{ throw new Error('quota'); }},
|
||||
removeItem:() => {{ throw new Error('quota'); }},
|
||||
}},
|
||||
getLogin:() => 'timmy',
|
||||
onError:() => {{ errors += 1; }},
|
||||
}});
|
||||
const session = buildMyWork.createWorkSession({{
|
||||
getItems:() => [1,2].map(number => ({{kind:'issue',repository:'stackchain/dashboard',number}})),
|
||||
getFilter:() => 'all', checkpoint,
|
||||
onOpen:item => opened.push(item.number), onProgress:() => {{}}, onFinish:() => {{}},
|
||||
}});
|
||||
session.start();
|
||||
session.next();
|
||||
process.stdout.write(JSON.stringify({{errors,opened,active:session.active()}}));
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["node", "-e", script], check=True, capture_output=True, text=True
|
||||
)
|
||||
|
||||
assert json.loads(result.stdout) == {"errors": 1, "opened": [1, 2], "active": True}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_mobile_work_session_renders_touch_safe_controls_for_every_work_sheet():
|
||||
html = await dashboard()
|
||||
@@ -1442,6 +1593,23 @@ async def test_mobile_work_session_renders_touch_safe_controls_for_every_work_sh
|
||||
assert 'aria-live="polite" data-work-session-progress' in html
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_dashboard_offers_account_safe_resume_and_end_today_controls():
|
||||
html = await dashboard()
|
||||
|
||||
assert 'id="resume-today-session"' in html
|
||||
assert 'id="end-today-session"' in html
|
||||
assert 'const sessionCheckpoint = createWorkSessionCheckpoint({' in html
|
||||
assert 'getLogin: () => confirmedOwnerLogin' in html
|
||||
assert 'checkpoint: sessionCheckpoint' in html
|
||||
assert 'checkpointEnabled: () => selectedWorkFilter === \'today\'' in html
|
||||
assert "qs('#resume-today-session').addEventListener('click'" in html
|
||||
assert "qs('#end-today-session').addEventListener('click'" in html
|
||||
assert "workSession.resume()" in html
|
||||
assert "workSession.end()" in html
|
||||
assert '.resume-today-session, .end-today-session { min-height:44px;' in html
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_dashboard_wires_work_session_to_existing_sheet_flows_and_completion_actions():
|
||||
html = await dashboard()
|
||||
|
||||
@@ -101,5 +101,5 @@ async def test_mobile_dashboard_wires_focused_plan_today_sheet():
|
||||
def test_plan_today_controller_is_available_in_the_offline_shell():
|
||||
source = SERVICE_WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v59" in source
|
||||
assert "stackchain-dashboard-shell-v60" in source
|
||||
assert "BASE + 'static/plan-today.js'" in source
|
||||
@@ -105,10 +105,19 @@ async function dispatchNotificationClick(route) {{
|
||||
return json.loads(completed.stdout)
|
||||
|
||||
|
||||
def test_resumable_today_session_ships_in_a_new_offline_shell():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v60" in source
|
||||
assert "BASE + 'static/my-work.js'" in source
|
||||
assert "BASE + 'static/dashboard.js'" in source
|
||||
assert "BASE + 'static/dashboard.css'" in source
|
||||
|
||||
|
||||
def test_duplicate_aware_capture_ships_in_a_new_offline_shell():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v59" in source
|
||||
assert "stackchain-dashboard-shell-v60" in source
|
||||
assert "BASE + 'static/create-issue-sheet.js'" in source
|
||||
assert "BASE + 'static/dashboard.js'" in source
|
||||
|
||||
@@ -116,14 +125,14 @@ def test_duplicate_aware_capture_ships_in_a_new_offline_shell():
|
||||
def test_exact_later_picker_ships_atomically_in_a_new_offline_shell():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v59" in source
|
||||
assert "stackchain-dashboard-shell-v60" in source
|
||||
assert "BASE + 'static/later-picker.js'" in source
|
||||
|
||||
|
||||
def test_navigation_deadline_ships_in_a_new_shell_cache():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v59" in source
|
||||
assert "stackchain-dashboard-shell-v60" in source
|
||||
assert "BASE + 'static/dashboard.css'" in source
|
||||
assert "BASE + 'static/dashboard.js'" in source
|
||||
assert "BASE + 'static/install-app.js'" in source
|
||||
@@ -132,21 +141,21 @@ def test_navigation_deadline_ships_in_a_new_shell_cache():
|
||||
def test_today_convergence_ships_in_a_new_shell_cache():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v59" in source
|
||||
assert "stackchain-dashboard-shell-v60" in source
|
||||
assert "BASE + 'static/today-sync.js'" in source
|
||||
|
||||
|
||||
def test_mobile_search_viewport_ships_in_a_new_offline_shell():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v59" in source
|
||||
assert "stackchain-dashboard-shell-v60" in source
|
||||
assert "BASE + 'static/mobile-search-viewport.js'" in source
|
||||
|
||||
|
||||
def test_update_ownership_flow_ships_atomically_in_a_new_offline_shell():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v59" in source
|
||||
assert "stackchain-dashboard-shell-v60" in source
|
||||
assert "BASE + 'static/update-ownership.js'" in source
|
||||
|
||||
|
||||
|
||||
@@ -86,7 +86,7 @@ sync.enqueue('add', 'issue:r:1:');
|
||||
def test_inflight_today_drain_ships_in_a_new_offline_shell():
|
||||
source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v59" in source
|
||||
assert "stackchain-dashboard-shell-v60" in source
|
||||
assert "BASE + 'static/today-sync.js'" in source
|
||||
|
||||
|
||||
|
||||
Reference in new issue
Block a user