import json import subprocess from pathlib import Path import pytest from tests.dashboard_bundle import dashboard TODAY_WORK = Path(__file__).parents[1] / "frontend" / "today-work.js" TODAY_COMPLETION = Path(__file__).parents[1] / "frontend" / "today-completion.js" SERVICE_WORKER = Path(__file__).parents[1] / "frontend" / "service-worker.js" def run_node(script): return subprocess.run( ["node", "-e", script], check=True, capture_output=True, text=True ).stdout @pytest.mark.anyio async def test_dashboard_surfaces_automatic_planning_retry_guidance(): html = await dashboard() assert "error.retryAfter = retryAfter === null ? undefined : Number(retryAfter)" in html assert "Today saved on this device · retrying" in html assert "Later saved on this device · retrying" in html def test_today_queue_is_account_scoped_ordered_unique_and_bounded(): script = f""" const createTodayWork = require({json.dumps(str(TODAY_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), }}; let login = 'timmy'; const queue = createTodayWork({{storage, getLogin: () => login, limit: 3}}); const issue = number => ({{kind:'issue', repository:'stackchain/dashboard', number, title:'Issue ' + number}}); const first = queue.add(issue(1)); const duplicate = queue.add(issue(1)); queue.add(issue(2)); queue.add(issue(3)); const full = queue.add(issue(4)); queue.move(issue(3), 'up'); queue.move(issue(3), 'up'); const timmy = queue.reconcile([issue(1), issue(2), issue(3), issue(4)]).map(item => item.number); const persisted = createTodayWork({{storage, getLogin: () => login, limit: 3}}) .reconcile([issue(1), issue(2), issue(3)]).map(item => item.number); login = 'alexander'; const isolated = queue.reconcile([issue(1), issue(2), issue(3)]).map(item => item.number); process.stdout.write(JSON.stringify({{first, duplicate, full, timmy, persisted, isolated}})); """ assert json.loads(run_node(script)) == { "first": "added", "duplicate": "exists", "full": "full", "timmy": [3, 1, 2], "persisted": [3, 1, 2], "isolated": [], } def test_today_capacity_metadata_is_account_scoped_and_prunes_removed_estimates(): script = f""" const createTodayWork = require({json.dumps(str(TODAY_WORK))}); const values = new Map(); const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}}; let login = 'timmy'; const queue = createTodayWork({{storage, getLogin: () => login}}); queue.replace(['issue:r:1:', 'issue:r:2:']); const saved = queue.replacePlanning({{capacity_minutes:120,estimates:{{'issue:r:1:':60,'issue:r:2:':45}}}}); queue.replace(['issue:r:2:']); const timmy = queue.planning(); login = 'alexander'; const alexander = queue.planning(); process.stdout.write(JSON.stringify({{saved,timmy,alexander}})); """ assert json.loads(run_node(script)) == { "saved": True, "timmy": {"capacity_minutes": 120, "estimates": {"issue:r:2:": 45}}, "alexander": {"capacity_minutes": None, "estimates": {}}, } def test_today_estimated_runway_follows_current_session_position(): script = f""" const createTodayWork = require({json.dumps(str(TODAY_WORK))}); const values = new Map(); const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}}; const queue = createTodayWork({{storage, getLogin:()=> 'timmy'}}); const item = number => ({{kind:'issue',repository:'r',number}}); queue.replace(['issue:r:1:','issue:r:2:','issue:r:3:']); queue.replacePlanning({{capacity_minutes:180,estimates:{{'issue:r:1:':30,'issue:r:2:':60,'issue:r:3:':45}}}}); process.stdout.write(JSON.stringify({{first:queue.runway([item(1),item(2),item(3)],0),second:queue.runway([item(1),item(2),item(3)],1)}})); """ assert json.loads(run_node(script)) == { "first": { "current_minutes": 30, "remaining_minutes": 135, "future_minutes": 105, "capacity_minutes": 180, }, "second": { "current_minutes": 60, "remaining_minutes": 105, "future_minutes": 45, "capacity_minutes": 180, }, } def test_today_live_runway_uses_elapsed_work_and_reports_capacity_risk(): script = f""" const createTodayWork = require({json.dumps(str(TODAY_WORK))}); const values = new Map(); const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}}; const queue = createTodayWork({{storage, getLogin:()=> 'timmy'}}); const item = number => ({{kind:'issue',repository:'r',number}}); queue.replace(['issue:r:1:','issue:r:2:']); queue.replacePlanning({{capacity_minutes:100,estimates:{{'issue:r:1:':30,'issue:r:2:':60}}}}); const healthy = queue.runway([item(1),item(2)],0,{{current_elapsed_ms:10*60000,total_elapsed_ms:10*60000}}); const over = queue.runway([item(1),item(2)],0,{{current_elapsed_ms:45*60000,total_elapsed_ms:45*60000}}); process.stdout.write(JSON.stringify({{healthy,over}})); """ assert json.loads(run_node(script)) == { "healthy": {"current_minutes": 30, "remaining_minutes": 90, "future_minutes": 60, "capacity_minutes": 100}, "over": {"current_minutes": 30, "remaining_minutes": 90, "future_minutes": 60, "capacity_minutes": 100}, } def test_today_live_runway_stays_truthful_when_estimates_or_capacity_are_missing(): script = f""" const createTodayWork = require({json.dumps(str(TODAY_WORK))}); const values = new Map(); const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}}; const queue = createTodayWork({{storage, getLogin:()=> 'timmy'}}); const item = number => ({{kind:'issue',repository:'r',number}}); queue.replace(['issue:r:1:','issue:r:2:']); queue.replacePlanning({{capacity_minutes:null,estimates:{{'issue:r:1:':30}}}}); process.stdout.write(JSON.stringify(queue.runway([item(1),item(2)],0,{{current_elapsed_ms:40*60000,total_elapsed_ms:40*60000}}))); """ assert json.loads(run_node(script)) == { "current_minutes": 30, "remaining_minutes": None, "future_minutes": None, "capacity_minutes": None, } def test_today_queue_exposes_reorder_boundaries_for_touch_controls(): script = f""" const createTodayWork = require({json.dumps(str(TODAY_WORK))}); const values = new Map(); const storage = {{ getItem: key => values.get(key) || null, setItem: (key, value) => values.set(key, value), removeItem: key => values.delete(key), }}; const queue = createTodayWork({{storage, getLogin: () => 'timmy'}}); const item = number => ({{kind:'issue', repository:'stackchain/dashboard', number}}); queue.add(item(1)); queue.add(item(2)); queue.add(item(3)); process.stdout.write(JSON.stringify([ queue.position(item(1)), queue.position(item(2)), queue.position(item(3)), queue.position(item(9)) ])); """ assert json.loads(run_node(script)) == [ {"can_up": False, "can_down": True}, {"can_up": True, "can_down": True}, {"can_up": True, "can_down": False}, {"can_up": False, "can_down": False}, ] def test_done_for_today_removes_current_plan_item_syncs_and_advances_without_gitea_mutation(): script = f""" const createTodayCompletion = require({json.dumps(str(TODAY_COMPLETION))}); const calls = []; const current = {{kind:'issue', repository:'stackchain/dashboard', number:421}}; const complete = createTodayCompletion({{ todayWork: {{ identity: item => `issue:${{item.repository}}:${{item.number}}:`, remove: item => {{ calls.push(`remove:${{item.number}}`); return true; }}, }}, todaySync: {{ enqueue: (action, identity) => {{ calls.push(`sync:${{action}}:${{identity}}`); return true; }}, flush: () => calls.push('flush'), }}, refresh: () => calls.push('refresh'), warm: () => calls.push('warm'), workSession: {{ complete: () => calls.push('advance') }}, announce: message => calls.push(`announce:${{message}}`), }}); const completed = complete(current); process.stdout.write(JSON.stringify({{completed, calls}})); """ assert json.loads(run_node(script)) == { "completed": True, "calls": [ "remove:421", "sync:remove:issue:stackchain/dashboard:421:", "flush", "refresh", "warm", "advance", "announce:Done for Today. The Gitea item is unchanged.", ], } def test_done_for_today_does_not_advance_when_local_plan_persistence_fails(): script = f""" const createTodayCompletion = require({json.dumps(str(TODAY_COMPLETION))}); const calls = []; const complete = createTodayCompletion({{ todayWork: {{identity: () => 'issue:r:1:', remove: () => false}}, todaySync: {{enqueue: () => calls.push('sync'), flush: () => calls.push('flush')}}, refresh: () => calls.push('refresh'), warm: () => calls.push('warm'), workSession: {{complete: () => calls.push('advance')}}, announce: message => calls.push(message), }}); const completed = complete({{kind:'issue', repository:'r', number:1}}); process.stdout.write(JSON.stringify({{completed, calls}})); """ assert json.loads(run_node(script)) == { "completed": False, "calls": ["Could not update Today on this device. Try again."], } def test_today_completion_reports_context_specific_success_and_partial_failure(): script = f""" const createTodayCompletion = require({json.dumps(str(TODAY_COMPLETION))}); const messages = []; let removable = false; const complete = createTodayCompletion({{ todayWork: {{identity: () => 'review:r:7:', remove: () => removable}}, todaySync: {{enqueue: () => true, flush: () => undefined}}, refresh: () => undefined, warm: () => undefined, workSession: {{complete: () => undefined}}, announce: message => messages.push(message), }}); const options = {{ successMessage: 'Review queued. Next Today item opened.', failureMessage: 'Review queued, but Today still needs completion.', }}; const failed = complete({{kind:'review', repository:'r', number:7}}, options); removable = true; const completed = complete({{kind:'review', repository:'r', number:7}}, options); process.stdout.write(JSON.stringify({{failed, completed, messages}})); """ assert json.loads(run_node(script)) == { "failed": False, "completed": True, "messages": [ "Review queued, but Today still needs completion.", "Review queued. Next Today item opened.", ], } def test_done_for_today_logic_is_available_in_the_offline_app_shell(): assert "BASE + 'static/today-completion.js'" in SERVICE_WORKER.read_text() def test_authoritative_reconciliation_reports_retired_ids_before_local_prune(): script = f""" const createTodayWork = require({json.dumps(str(TODAY_WORK))}); const values = new Map(); const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}}; const queue = createTodayWork({{storage, getLogin: () => 'timmy'}}); const item = number => ({{kind:'issue', repository:'r', number}}); queue.add(item(1)); queue.add(item(2)); const reports = []; const visible = queue.reconcile([item(2)], {{ pruneMissing: true, onPrune: retiredIds => reports.push({{retiredIds, idsDuringReport: queue.read()}}), }}); queue.reconcile([item(2)], {{ pruneMissing: true, onPrune: retiredIds => reports.push({{retiredIds, idsDuringReport: queue.read()}}), }}); process.stdout.write(JSON.stringify({{reports, visible:visible.map(entry => entry.number), stored:queue.read()}})); """ assert json.loads(run_node(script)) == { "reports": [{ "retiredIds": ["issue:r:1:"], "idsDuringReport": ["issue:r:1:", "issue:r:2:"], }], "visible": [2], "stored": ["issue:r:2:"], } def test_failed_retirement_queue_keeps_local_identity_for_retry(): script = f""" const createTodayWork = require({json.dumps(str(TODAY_WORK))}); const values = new Map(); const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}}; const queue = createTodayWork({{storage, getLogin: () => 'timmy'}}); const item = number => ({{kind:'issue', repository:'r', number}}); queue.add(item(1)); const visible = queue.reconcile([], {{pruneMissing:true, onPrune:() => false}}); process.stdout.write(JSON.stringify({{visible, stored:queue.read()}})); """ assert json.loads(run_node(script)) == { "visible": [], "stored": ["issue:r:1:"], } def test_today_queue_adopts_bounded_server_order(): script = f""" const createTodayWork = require({json.dumps(str(TODAY_WORK))}); const values = new Map(); const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}}; const queue = createTodayWork({{storage, getLogin: () => 'timmy', limit: 2}}); queue.add({{kind:'issue', repository:'r', number:1}}); const adopted = queue.replace(['issue:r:3:', 'issue:r:2:', 'issue:r:2:', '', 'issue:r:1:']); process.stdout.write(JSON.stringify({{adopted, ids:queue.read()}})); """ assert json.loads(run_node(script)) == { "adopted": True, "ids": ["issue:r:3:", "issue:r:2:"], } @pytest.mark.anyio async def test_dashboard_runs_the_curated_today_queue_as_a_mobile_work_flow(): html = await dashboard() assert '' in html assert '' in html assert 'data-work-filter="today"' in html assert 'data-work-count="today"' in html assert "const todayWork = createTodayWork({" in html assert "getTodayCount: () => todayMyWork.length" in html assert "startToday: startTodaySession" in html assert "selectedWorkFilter === 'today' ? todayMyWork : activeMyWork" in html assert 'data-today-add' in html assert 'data-today-remove' in html assert 'data-today-move="up"' in html assert 'data-today-move="down"' in html assert 'Today is limited to 5 items' in html assert '.today-actions button' in html and 'min-height:44px' in html assert 'id="today-sync-status"' in html assert "todaySync.enqueue('add'" in html assert "todaySync.enqueue('remove'" in html assert "todaySync.enqueue('move'" in html assert "const authoritativeTodayReconciliation = liveMode && hasContextSnapshot &&" in html assert "!lastContextSnapshot?.error &&" in html assert "onPrune: retiredIds =>" in html assert "retiredIds.map(id => todaySync.enqueue('remove', id)).every(Boolean)" in html assert "if (queued) todaySync.flush();" in html assert "todaySync.flush();" in html assert "Another device filled Today · showing its saved plan." in html @pytest.mark.anyio async def test_today_session_sheets_offer_a_touch_safe_plan_only_completion_action(): html = await dashboard() assert '' in html assert html.count('data-work-session-complete=') == 4 assert html.count('Done for Today & next') == 4 assert "button.hidden = !workSession.checkpointed();" in html assert "const completeTodayItem = createTodayCompletion({" in html assert "refresh: () => refreshMyWorkView({ reconcileSession:false })," in html assert "document.querySelectorAll('[data-work-session-complete]')" in html assert "completeTodayItem(selectedSessionItem(button.dataset.workSessionComplete))" in html assert ".work-session-nav button { min-height:44px; width:100%; }" in html assert "grid-template-columns:repeat(2,minmax(0,1fr))" in html @pytest.mark.anyio async def test_dashboard_activates_today_lifecycle_convergence(): html = await dashboard() assert "todaySync.startLifecycle({ window, document });" in html @pytest.mark.anyio async def test_retained_authenticated_context_keeps_local_planning_separate_from_fresh_delivery(): html = await dashboard() assert "let planningOwnerLogin = '';" in html assert html.count("getLogin: () => planningOwnerLogin") == 4 assert "const retainedPlanningLogin = !snapshot.context.error ?" in html assert "planningOwnerLogin = retainedPlanningLogin;" in html assert "button.disabled = !planningOwnerLogin;" in html assert "activeFlushLogin = contextIdentityFresh ?" in html assert "getOwnerLogin: () => confirmedOwnerLogin" in html assert "Planning is unavailable until your operator identity is restored." in html assert "Could not save Today on this device." in html assert "Could not save Later on this device." in html assert "data-planning-disabled" in html