import json import subprocess from pathlib import Path ROOT = Path(__file__).parents[1] TIMER = ROOT / "frontend" / "today-timer.js" BREAK = ROOT / "frontend" / "today-break.js" def run_node(source: str) -> dict: completed = subprocess.run( ["node", "-e", source], capture_output=True, text=True ) assert completed.returncode == 0, completed.stderr return json.loads(completed.stdout) def test_timed_break_pauses_immediately_and_restores_absolute_deadline(): script = TIMER.read_text() + r""" const values = new Map(); const storage = {getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value)}; let now = 100000; const timer = createTodayTimer({storage,getLogin:()=> 'timmy',now:()=>now}); timer.activate('issue:r:42:'); now = 112000; const started = timer.startBreak(15); const paused = timer.snapshot(); now = 200000; const restored = createTodayTimer({storage,getLogin:()=> 'timmy',now:()=>now}).breakSnapshot(); process.stdout.write(JSON.stringify({started,paused,restored})); """ assert run_node(script) == { "started": { "identity": "issue:r:42:", "deadline_at": 1012000, "expired": False, }, "paused": { "identity": "issue:r:42:", "elapsed_ms": 12000, "running": False, "break_deadline_at": 1012000, }, "restored": { "identity": "issue:r:42:", "deadline_at": 1012000, "expired": False, }, } def test_timed_break_snapshot_carries_deadline_for_cross_device_sync(): script = TIMER.read_text() + r""" const values = new Map(); const storage = {getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value)}; const timer = createTodayTimer({storage,getLogin:()=> 'timmy',now:()=>100000}); timer.activate('issue:r:42:'); timer.startBreak(15); process.stdout.write(JSON.stringify(timer.snapshot())); """ assert run_node(script) == { "identity": "issue:r:42:", "elapsed_ms": 0, "running": False, "break_deadline_at": 1_000_000, } def test_break_expiry_never_restarts_time_and_resume_is_explicit_and_idempotent(): script = TIMER.read_text() + r""" const values = new Map(); const storage = {getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value)}; let now = 0; const timer = createTodayTimer({storage,getLogin:()=> 'timmy',now:()=>now}); timer.activate('issue:r:42:'); const invalid = [timer.startBreak(0), timer.startBreak(121), timer.startBreak(1.5)]; const started = timer.startBreak(5); now = 300001; const expired = timer.breakSnapshot(); const stillPaused = timer.snapshot(); const resumed = timer.resumeBreak(); now = 305001; const running = timer.snapshot(); const replay = timer.resumeBreak(); process.stdout.write(JSON.stringify({invalid,started,expired,stillPaused,resumed,running,replay,remaining:timer.breakSnapshot()})); """ result = run_node(script) assert result["invalid"] == [False, False, False] assert result["started"]["deadline_at"] == 300000 assert result["expired"]["expired"] is True assert result["stillPaused"]["running"] is False assert result["resumed"] is True assert result["running"] == { "identity": "issue:r:42:", "elapsed_ms": 5000, "running": True } assert result["replay"] is False assert result["remaining"] is None def test_mobile_break_sheet_starts_counts_down_and_requires_explicit_resume(): script = f""" const createBreak = require({json.dumps(str(BREAK))}); class Element {{ constructor() {{ this.listeners={{}}; this.hidden=false; this.textContent=''; this.value=''; this.open=false; this.dataset={{}}; }} addEventListener(name, fn) {{ this.listeners[name]=fn; }} click() {{ return this.listeners.click?.({{preventDefault(){{}}}}); }} showModal() {{ this.open=true; }} close() {{ this.open=false; this.listeners.close?.(); }} focus() {{}} }} let now=100000; let pending=null; const calls=[]; const timer={{ snapshot:()=>({{identity:'issue:r:42:',elapsed_ms:5000,running:!pending}}), breakSnapshot:()=>pending && {{...pending,expired:now>=pending.deadline_at}}, startBreak:minutes=>{{calls.push(['start',minutes]);pending={{identity:'issue:r:42:',deadline_at:now+minutes*60000}};return {{...pending,expired:false}};}}, resumeBreak:()=>{{if(!pending)return false;calls.push(['resume']);pending=null;return true;}}, }}; const sheet=new Element(), opener=new Element(), cancel=new Element(), preset=new Element(), status=new Element(), resume=new Element(); preset.dataset.todayBreakMinutes='15'; let tick; const controller=createBreak({{timer,sheet,openButtons:[opener],cancelButton:cancel,presetButtons:[preset],status,resumeButton:resume,now:()=>now,setIntervalRef:fn=>(tick=fn,1),clearIntervalRef:()=>{{}},onChange:()=>calls.push(['change']),onResume:identity=>calls.push(['return',identity])}}); opener.click(); const opened=sheet.open; cancel.click(); const cancelled={{open:sheet.open,calls:[...calls]}}; opener.click(); preset.click(); const started={{open:sheet.open,text:status.textContent,statusHidden:status.hidden,resumeHidden:resume.hidden,openerHidden:opener.hidden}}; now=100000+15*60000+1; tick(); const expired={{text:status.textContent,resumeHidden:resume.hidden,running:timer.snapshot().running}}; resume.click(); process.stdout.write(JSON.stringify({{opened,cancelled,started,expired,final:{{calls,statusHidden:status.hidden,openerHidden:opener.hidden,pending}}}})); """ assert run_node(script) == { "opened": True, "cancelled": {"open": False, "calls": []}, "started": { "open": False, "text": "On break · resume in 15:00", "statusHidden": False, "resumeHidden": False, "openerHidden": True, }, "expired": { "text": "Break over · ready to resume", "resumeHidden": False, "running": False, }, "final": { "calls": [["start", 15], ["change"], ["resume"], ["return", "issue:r:42:"], ["change"]], "statusHidden": True, "openerHidden": False, "pending": None, }, } def test_timed_break_is_packaged_as_an_accessible_mobile_flow(): html = (ROOT / "frontend" / "index.html").read_text() css = (ROOT / "frontend" / "dashboard.css").read_text() dashboard = (ROOT / "frontend" / "dashboard.js").read_text() bundle = (ROOT / "src" / "frontend_bundle.py").read_text() assert 'data-today-break-open' in html assert 'id="today-break-sheet"' in html assert 'data-today-break-minutes="5"' in html assert 'data-today-break-minutes="15"' in html assert 'data-today-break-minutes="30"' in html assert 'id="today-break-custom-minutes"' in html assert 'min="1" max="120" step="1"' in html assert 'id="today-break-status"' in html assert 'id="resume-today-break"' in html assert '' in html assert "createTodayBreak({" in (ROOT / "frontend" / "today-timer.js").read_text() assert '"static/today-break.js"' in bundle assert ".today-break-panel" in css assert "overflow-x:hidden" in css assert ".today-break-actions button" in css assert "min-height:44px" in css def test_custom_break_validation_is_side_effect_free_until_valid(): script = f""" const createBreak = require({json.dumps(str(BREAK))}); class Element {{ constructor() {{this.listeners={{}};this.open=true;this.hidden=false;this.value='';this.textContent='';}} addEventListener(name,fn){{this.listeners[name]=fn;}} close(){{this.open=false;}} focus(){{this.focused=true;}} }} const form=new Element(), input=new Element(), error=new Element(), status=new Element(), resume=new Element(), sheet=new Element(); const calls=[]; const timer={{snapshot:()=>({{identity:'issue:r:1:'}}),breakSnapshot:()=>null,startBreak:value=>{{calls.push(value);return value===45;}},resumeBreak:()=>false}}; createBreak({{timer,sheet,form,minutesInput:input,error,status,resumeButton:resume,setIntervalRef:()=>1,clearIntervalRef:()=>{{}}}}); input.value='2.5'; form.listeners.submit({{preventDefault(){{}}}}); const invalid={{open:sheet.open,error:error.textContent,focused:input.focused||false}}; input.value='45'; form.listeners.submit({{preventDefault(){{}}}}); process.stdout.write(JSON.stringify({{invalid,valid:{{open:sheet.open,error:error.textContent}},calls}})); """ assert run_node(script) == { "invalid": { "open": True, "error": "Choose a whole number from 1 to 120 minutes.", "focused": True, }, "valid": {"open": False, "error": ""}, "calls": [2.5, 45], } def test_replacing_or_stopping_active_work_clears_an_old_break(): script = TIMER.read_text() + r""" const values=new Map(); const storage={getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value)}; let now=0; const timer=createTodayTimer({storage,getLogin:()=> 'timmy',now:()=>now}); timer.activate('issue:r:1:'); timer.startBreak(5); timer.activate('issue:r:2:'); const replaced=timer.breakSnapshot(); timer.startBreak(5); timer.stop(); const stopped=timer.breakSnapshot(); timer.activate('issue:r:3:'); timer.startBreak(5); timer.adopt('issue:r:4:',1000,false); process.stdout.write(JSON.stringify({replaced,stopped,adopted:timer.breakSnapshot()})); """ assert run_node(script) == {"replaced": None, "stopped": None, "adopted": None} def test_browser_back_closes_break_sheet_without_starting_a_break(): script = f""" const createBreak=require({json.dumps(str(BREAK))}); class Element{{constructor(){{this.listeners={{}};this.open=false;this.hidden=true;}}addEventListener(n,f){{this.listeners[n]=f;}}showModal(){{this.open=true;}}close(){{this.open=false;}}}} const sheet=new Element(), opener=new Element(), status=new Element(), resume=new Element(); const listeners={{}}, pushes=[]; const historyRef={{state:{{route:'today'}},pushState:state=>{{historyRef.state=state;pushes.push(state);}},back:()=>listeners.popstate?.()}}; const windowRef={{addEventListener:(name,fn)=>listeners[name]=fn}}; const timer={{snapshot:()=>({{identity:'issue:r:1:'}}),breakSnapshot:()=>null,resumeBreak:()=>false}}; createBreak({{timer,sheet,openButtons:[opener],status,resumeButton:resume,historyRef,windowRef,queryAll:()=>[]}}); opener.listeners.click(); const opened={{open:sheet.open,state:historyRef.state,pushes}}; listeners.popstate(); process.stdout.write(JSON.stringify({{opened,closed:!sheet.open}})); """ assert run_node(script) == { "opened": { "open": True, "state": {"route": "today", "todayBreak": True}, "pushes": [{"route": "today", "todayBreak": True}], }, "closed": True, }