434 lines
17 KiB
Python
434 lines
17 KiB
Python
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_reactivating_the_active_item_during_reload_preserves_its_timed_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 = 100000;
|
|
const timer = createTodayTimer({storage,getLogin:()=> 'timmy',now:()=>now});
|
|
timer.activate('issue:r:42:');
|
|
timer.startBreak(5);
|
|
const reopened = timer.activate('issue:r:42:');
|
|
process.stdout.write(JSON.stringify({reopened,snapshot:timer.snapshot(),pending:timer.breakSnapshot()}));
|
|
"""
|
|
|
|
assert run_node(script) == {
|
|
"reopened": True,
|
|
"snapshot": {
|
|
"identity": "issue:r:42:",
|
|
"elapsed_ms": 0,
|
|
"running": False,
|
|
"break_deadline_at": 400_000,
|
|
},
|
|
"pending": {
|
|
"identity": "issue:r:42:",
|
|
"deadline_at": 400_000,
|
|
"expired": False,
|
|
},
|
|
}
|
|
|
|
|
|
def test_adopting_paused_state_for_same_item_preserves_valid_local_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)};
|
|
const timer = createTodayTimer({storage,getLogin:()=> 'timmy',now:()=>100000});
|
|
timer.activate('issue:r:42:');
|
|
timer.startBreak(5);
|
|
const adopted = timer.adopt('issue:r:42:', 12000, false, [
|
|
{identity:'issue:r:42:',elapsed_ms:12000},
|
|
]);
|
|
process.stdout.write(JSON.stringify({adopted,snapshot:timer.snapshot(),pending:timer.breakSnapshot()}));
|
|
"""
|
|
|
|
assert run_node(script) == {
|
|
"adopted": True,
|
|
"snapshot": {
|
|
"identity": "issue:r:42:",
|
|
"elapsed_ms": 12000,
|
|
"running": False,
|
|
"break_deadline_at": 400_000,
|
|
},
|
|
"pending": {
|
|
"identity": "issue:r:42:",
|
|
"deadline_at": 400_000,
|
|
"expired": False,
|
|
},
|
|
}
|
|
|
|
|
|
def test_reload_running_checkpoint_cannot_restart_item_during_valid_local_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)};
|
|
const timer = createTodayTimer({storage,getLogin:()=> 'timmy',now:()=>100000});
|
|
timer.activate('issue:r:42:');
|
|
timer.startBreak(5);
|
|
const adopted = timer.adopt('issue:r:42:', 12000, true, [
|
|
{identity:'issue:r:42:',elapsed_ms:12000},
|
|
]);
|
|
process.stdout.write(JSON.stringify({adopted,snapshot:timer.snapshot(),pending:timer.breakSnapshot()}));
|
|
"""
|
|
|
|
assert run_node(script) == {
|
|
"adopted": True,
|
|
"snapshot": {
|
|
"identity": "issue:r:42:",
|
|
"elapsed_ms": 12000,
|
|
"running": False,
|
|
"break_deadline_at": 400_000,
|
|
},
|
|
"pending": {
|
|
"identity": "issue:r:42:",
|
|
"deadline_at": 400_000,
|
|
"expired": False,
|
|
},
|
|
}
|
|
|
|
|
|
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_identity_ready_restore_renders_break_again_after_sync_recovery_settles():
|
|
script = f"""
|
|
const createBreak = require({json.dumps(str(BREAK))});
|
|
global.createTodayBreak = createBreak;
|
|
global.setInterval = () => 1;
|
|
global.clearInterval = () => {{}};
|
|
Date.now = () => 100000;
|
|
const createTimer = require({json.dumps(str(TIMER))});
|
|
const createSync = require({json.dumps(str(ROOT / "frontend" / "today-sync.js"))});
|
|
const values = new Map();
|
|
const storage = {{
|
|
get length(){{return values.size;}},
|
|
key:index=>Array.from(values.keys())[index]??null,
|
|
getItem:key=>values.get(key)||null,
|
|
setItem:(key,value)=>values.set(key,value),
|
|
removeItem:key=>values.delete(key),
|
|
}};
|
|
let login = '';
|
|
values.set('stackchain.today-timer.v1.timmy', JSON.stringify({{
|
|
version:1, active_identity:'issue:r:42:',
|
|
entries:{{'issue:r:42:':{{elapsed_ms:12000,started_at:null,running:false}}}},
|
|
away_at:null,pending_interruption:null,attention_interruption:null,capture_interruption:null,
|
|
search_interruption:null,detour_interruption:null,
|
|
timed_break:{{identity:'issue:r:42:',deadline_at:400000}},
|
|
}}));
|
|
const status = {{hidden:false,textContent:''}}, resume = {{hidden:false,addEventListener(){{}}}};
|
|
const opener = {{hidden:false,addEventListener(){{}}}};
|
|
const elements = {{'#today-break-status':[status],'#resume-today-break':[resume],'[data-today-break-open]':[opener]}};
|
|
const timer = createTimer({{storage,getLogin:()=>login,now:()=>100000}});
|
|
const view = createTimer.createView({{
|
|
timer,isActive:()=>true,queryAll:selector=>elements[selector]||[],
|
|
formatEstimate:value=>String(value),getItem:()=>({{title:'Ship mobile capture'}}),
|
|
}});
|
|
login = 'timmy';
|
|
values.set('stackchain.today-sync.v1.timmy.operation.broken', '{{not-json');
|
|
const statuses = [];
|
|
const sync = createSync({{
|
|
storage,getLogin:()=>login,now:()=>100000,
|
|
fetchJson:async()=>({{revision:1,ids:['issue:r:42:'],capacity_minutes:null,estimates:{{}}}}),
|
|
onStatus:(state,detail)=>statuses.push([state,detail.discarded||0]),
|
|
}});
|
|
const recovery = sync.flush();
|
|
const restoring = view.restore(recovery);
|
|
const immediate = {{hidden:status.hidden,text:status.textContent,openerHidden:opener.hidden}};
|
|
status.hidden = true; status.textContent = ''; opener.hidden = false;
|
|
restoring.then(result => {{
|
|
const settled = {{hidden:status.hidden,text:status.textContent,openerHidden:opener.hidden}};
|
|
status.hidden = true; status.textContent = ''; opener.hidden = false;
|
|
return view.restore(Promise.reject(new Error('coordinator unavailable'))).then(rejected =>
|
|
process.stdout.write(JSON.stringify({{
|
|
immediate, settled, rejectedSettled:{{hidden:status.hidden,text:status.textContent,openerHidden:opener.hidden}},
|
|
result,rejected,statuses,
|
|
}}))
|
|
);
|
|
}});
|
|
"""
|
|
|
|
assert run_node(script) == {
|
|
"immediate": {
|
|
"hidden": False,
|
|
"text": "On break · resume in 5:00",
|
|
"openerHidden": True,
|
|
},
|
|
"settled": {
|
|
"hidden": False,
|
|
"text": "On break · resume in 5:00",
|
|
"openerHidden": True,
|
|
},
|
|
"rejectedSettled": {
|
|
"hidden": False,
|
|
"text": "On break · resume in 5:00",
|
|
"openerHidden": True,
|
|
},
|
|
"result": True,
|
|
"rejected": True,
|
|
"statuses": [["recovered", 1]],
|
|
}
|
|
|
|
dashboard = (ROOT / "frontend" / "dashboard.js").read_text()
|
|
assert "timerView.restore(todaySync.flush());" in dashboard
|
|
|
|
|
|
def test_fresh_unchanged_context_restores_the_confirmed_break_owner():
|
|
dashboard = (ROOT / "frontend" / "dashboard.js").read_text()
|
|
snapshot_handler = dashboard.split("async function renderLiveSnapshot", 1)[1]
|
|
identity_restore, changed_render = snapshot_handler.split(
|
|
"if (snapshot.context && workChanged)", 1
|
|
)
|
|
assert "activeFlushLogin = contextIdentityFresh ?" in identity_restore
|
|
assert "confirmedOwnerLogin = activeFlushLogin;" in identity_restore
|
|
assert "timerView.restore(todaySync.flush());" in identity_restore
|
|
assert "activeFlushLogin = contextIdentityFresh ?" not in changed_render.split(
|
|
"if (eventsChanged", 1
|
|
)[0]
|
|
|
|
|
|
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 '<script src="static/today-break.js"></script>' 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,
|
|
}
|