386 lines
16 KiB
Python
386 lines
16 KiB
Python
import json
|
|
import subprocess
|
|
from pathlib import Path
|
|
|
|
|
|
SOURCE = Path(__file__).parents[1] / "frontend" / "today-timer.js"
|
|
|
|
|
|
def test_timer_adopts_claimed_elapsed_time():
|
|
script = SOURCE.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});
|
|
const adopted = timer.adopt('issue:r:42:', 90000, true);
|
|
now = 105000;
|
|
process.stdout.write(JSON.stringify({adopted, snapshot:timer.snapshot()}));
|
|
"""
|
|
completed = subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
|
|
result = json.loads(completed.stdout)
|
|
|
|
assert result == {
|
|
"adopted": True,
|
|
"snapshot": {"identity": "issue:r:42:", "elapsed_ms": 95000, "running": True},
|
|
}
|
|
|
|
|
|
def test_timer_reports_durable_changes_for_session_sync():
|
|
script = SOURCE.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 changes = [];
|
|
const timer = createTodayTimer({
|
|
storage, getLogin:()=> 'timmy', now:()=>now,
|
|
onChange:snapshot=>changes.push(snapshot),
|
|
});
|
|
timer.activate('issue:r:42:');
|
|
now = 5000;
|
|
timer.pause();
|
|
process.stdout.write(JSON.stringify(changes));
|
|
"""
|
|
completed = subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
|
|
|
|
assert json.loads(completed.stdout) == [
|
|
{"identity": "issue:r:42:", "elapsed_ms": 0, "running": True},
|
|
{"identity": "issue:r:42:", "elapsed_ms": 5000, "running": False},
|
|
]
|
|
|
|
|
|
def test_quick_capture_pauses_only_its_running_item_and_restores_once():
|
|
script = SOURCE.read_text() + r"""
|
|
const values = new Map();
|
|
const storage = {getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value)};
|
|
let now = 1000;
|
|
const timer = createTodayTimer({storage, getLogin:()=> 'timmy', now:()=>now});
|
|
timer.activate('issue:r:42:');
|
|
now = 6000;
|
|
const interruption = timer.beginCapture();
|
|
now = 16000;
|
|
const paused = timer.snapshot();
|
|
const returned = timer.returnFromCapture();
|
|
now = 18000;
|
|
const restored = timer.snapshot();
|
|
const repeated = timer.returnFromCapture();
|
|
process.stdout.write(JSON.stringify({interruption, paused, returned, restored, repeated}));
|
|
"""
|
|
completed = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
|
|
|
assert completed.returncode == 0, completed.stderr
|
|
assert json.loads(completed.stdout) == {
|
|
"interruption": {"identity": "issue:r:42:", "resume": True},
|
|
"paused": {"identity": "issue:r:42:", "elapsed_ms": 5000, "running": False},
|
|
"returned": {"identity": "issue:r:42:", "resumed": True},
|
|
"restored": {"identity": "issue:r:42:", "elapsed_ms": 7000, "running": True},
|
|
"repeated": None,
|
|
}
|
|
|
|
|
|
def test_quick_capture_does_not_resume_an_already_paused_or_replaced_item():
|
|
script = SOURCE.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.adopt('issue:r:1:', 3000, false);
|
|
const pausedInterruption = timer.beginCapture();
|
|
const pausedReturn = timer.returnFromCapture();
|
|
const stayedPaused = timer.snapshot();
|
|
timer.activate('issue:r:1:');
|
|
const runningInterruption = timer.beginCapture();
|
|
timer.activate('issue:r:2:');
|
|
const replacedReturn = timer.returnFromCapture();
|
|
const replacement = timer.snapshot();
|
|
process.stdout.write(JSON.stringify({pausedInterruption, pausedReturn, stayedPaused, runningInterruption, replacedReturn, replacement}));
|
|
"""
|
|
completed = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
|
|
|
assert completed.returncode == 0, completed.stderr
|
|
assert json.loads(completed.stdout) == {
|
|
"pausedInterruption": {"identity": "issue:r:1:", "resume": False},
|
|
"pausedReturn": {"identity": "issue:r:1:", "resumed": False},
|
|
"stayedPaused": {"identity": "issue:r:1:", "elapsed_ms": 3000, "running": False},
|
|
"runningInterruption": {"identity": "issue:r:1:", "resume": True},
|
|
"replacedReturn": {"identity": "issue:r:1:", "resumed": False},
|
|
"replacement": {"identity": "issue:r:2:", "elapsed_ms": 0, "running": True},
|
|
}
|
|
|
|
|
|
def test_create_and_start_abandons_the_interrupted_timer():
|
|
script = SOURCE.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:()=>5000});
|
|
timer.activate('issue:r:1:');
|
|
timer.beginCapture();
|
|
const abandoned = timer.abandonCapture();
|
|
const snapshot = timer.snapshot();
|
|
const repeated = timer.returnFromCapture();
|
|
process.stdout.write(JSON.stringify({abandoned, snapshot, repeated}));
|
|
"""
|
|
completed = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
|
|
|
assert completed.returncode == 0, completed.stderr
|
|
assert json.loads(completed.stdout) == {
|
|
"abandoned": {"identity": "issue:r:1:", "resumed": False},
|
|
"snapshot": {"identity": "issue:r:1:", "elapsed_ms": 0, "running": False},
|
|
"repeated": None,
|
|
}
|
|
|
|
|
|
def test_capture_interruption_view_names_work_and_returns_to_today():
|
|
script = SOURCE.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:()=>1000});
|
|
const banner = {hidden:true};
|
|
const label = {textContent:''};
|
|
const returns = [];
|
|
const view = createTodayTimer.createCaptureInterruption({
|
|
timer, banner, label,
|
|
getItemLabel:identity=>identity === 'issue:r:1:' ? 'Ship release' : '',
|
|
onReturn:result=>returns.push(result),
|
|
});
|
|
timer.activate('issue:r:1:');
|
|
const opened = view.open();
|
|
const shown = {hidden:banner.hidden, text:label.textContent, timer:timer.snapshot()};
|
|
const finished = view.finish();
|
|
const closed = {hidden:banner.hidden, timer:timer.snapshot(), returns};
|
|
timer.beginCapture();
|
|
const transferred = view.transfer();
|
|
process.stdout.write(JSON.stringify({opened, shown, finished, closed, transferred, final:timer.snapshot()}));
|
|
"""
|
|
completed = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
|
|
|
assert completed.returncode == 0, completed.stderr
|
|
assert json.loads(completed.stdout) == {
|
|
"opened": {"identity": "issue:r:1:", "resume": True},
|
|
"shown": {
|
|
"hidden": False,
|
|
"text": "Today paused · Ship release",
|
|
"timer": {"identity": "issue:r:1:", "elapsed_ms": 0, "running": False},
|
|
},
|
|
"finished": {"identity": "issue:r:1:", "resumed": True},
|
|
"closed": {
|
|
"hidden": True,
|
|
"timer": {"identity": "issue:r:1:", "elapsed_ms": 0, "running": True},
|
|
"returns": [{"identity": "issue:r:1:", "resumed": True}],
|
|
},
|
|
"transferred": {"identity": "issue:r:1:", "resumed": False},
|
|
"final": {"identity": "issue:r:1:", "elapsed_ms": 0, "running": False},
|
|
}
|
|
|
|
|
|
def test_search_leaves_manually_paused_work_alone_and_cannot_restore_replaced_work():
|
|
script = SOURCE.read_text() + r"""
|
|
const values = new Map();
|
|
const storage = {getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value)};
|
|
let now = 1000;
|
|
const timer = createTodayTimer({storage, getLogin:()=> 'timmy', now:()=>now});
|
|
timer.adopt('issue:r:1:', 3000, false);
|
|
const pausedSearch = timer.beginSearch();
|
|
timer.activate('issue:r:1:');
|
|
const runningSearch = timer.beginSearch();
|
|
timer.activate('issue:r:2:');
|
|
const replacedReturn = timer.returnFromSearch();
|
|
process.stdout.write(JSON.stringify({pausedSearch, runningSearch, replacedReturn, snapshot:timer.snapshot()}));
|
|
"""
|
|
completed = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
|
|
|
assert completed.returncode == 0, completed.stderr
|
|
assert json.loads(completed.stdout) == {
|
|
"pausedSearch": None,
|
|
"runningSearch": {"identity": "issue:r:1:", "resume": True},
|
|
"replacedReturn": None,
|
|
"snapshot": {"identity": "issue:r:2:", "elapsed_ms": 0, "running": True},
|
|
}
|
|
|
|
|
|
def test_search_pauses_running_today_time_and_restores_exactly_once():
|
|
script = SOURCE.read_text() + r"""
|
|
const values = new Map();
|
|
const storage = {getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value)};
|
|
let now = 1000;
|
|
const timer = createTodayTimer({storage, getLogin:()=> 'timmy', now:()=>now});
|
|
timer.activate('issue:r:42:');
|
|
now = 6000;
|
|
const interruption = timer.beginSearch();
|
|
now = 26000;
|
|
const paused = timer.snapshot();
|
|
const returned = timer.returnFromSearch();
|
|
now = 28000;
|
|
const restored = timer.snapshot();
|
|
const repeated = timer.returnFromSearch();
|
|
process.stdout.write(JSON.stringify({interruption, paused, returned, restored, repeated}));
|
|
"""
|
|
completed = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
|
|
|
assert completed.returncode == 0, completed.stderr
|
|
assert json.loads(completed.stdout) == {
|
|
"interruption": {"identity": "issue:r:42:", "resume": True},
|
|
"paused": {"identity": "issue:r:42:", "elapsed_ms": 5000, "running": False},
|
|
"returned": {"identity": "issue:r:42:", "resumed": True},
|
|
"restored": {"identity": "issue:r:42:", "elapsed_ms": 7000, "running": True},
|
|
"repeated": None,
|
|
}
|
|
|
|
|
|
def test_mobile_detour_pauses_running_today_time_and_restores_exactly_once():
|
|
script = SOURCE.read_text() + r"""
|
|
const values = new Map();
|
|
const storage = {getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value)};
|
|
let now = 1000;
|
|
const timer = createTodayTimer({storage, getLogin:()=> 'timmy', now:()=>now});
|
|
timer.activate('issue:r:42:');
|
|
now = 6000;
|
|
const interruption = timer.beginDetour('find');
|
|
now = 26000;
|
|
const paused = timer.snapshot();
|
|
const durable = timer.detourInterruption();
|
|
const returned = timer.returnFromDetour();
|
|
now = 28000;
|
|
const restored = timer.snapshot();
|
|
const repeated = timer.returnFromDetour();
|
|
process.stdout.write(JSON.stringify({interruption, paused, durable, returned, restored, repeated}));
|
|
"""
|
|
completed = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
|
|
|
assert completed.returncode == 0, completed.stderr
|
|
assert json.loads(completed.stdout) == {
|
|
"interruption": {"identity": "issue:r:42:", "resume": True, "reason": "find"},
|
|
"paused": {"identity": "issue:r:42:", "elapsed_ms": 5000, "running": False},
|
|
"durable": {"identity": "issue:r:42:", "resume": True, "reason": "find"},
|
|
"returned": {"identity": "issue:r:42:", "resumed": True, "reason": "find"},
|
|
"restored": {"identity": "issue:r:42:", "elapsed_ms": 7000, "running": True},
|
|
"repeated": None,
|
|
}
|
|
|
|
|
|
def test_insights_detour_excludes_review_time_and_survives_reload():
|
|
script = SOURCE.read_text() + r"""
|
|
const values = new Map();
|
|
const storage = {getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value)};
|
|
let now = 1000;
|
|
let timer = createTodayTimer({storage, getLogin:()=> 'timmy', now:()=>now});
|
|
timer.activate('issue:r:42:');
|
|
now = 6000;
|
|
const interruption = timer.beginDetour('insights');
|
|
now = 26000;
|
|
timer = createTodayTimer({storage, getLogin:()=> 'timmy', now:()=>now});
|
|
const restored = timer.detourInterruption();
|
|
const paused = timer.snapshot();
|
|
const returned = timer.returnFromDetour();
|
|
now = 28000;
|
|
process.stdout.write(JSON.stringify({interruption, restored, paused, returned, resumed:timer.snapshot()}));
|
|
"""
|
|
completed = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
|
|
|
assert completed.returncode == 0, completed.stderr
|
|
assert json.loads(completed.stdout) == {
|
|
"interruption": {"identity": "issue:r:42:", "resume": True, "reason": "insights"},
|
|
"restored": {"identity": "issue:r:42:", "resume": True, "reason": "insights"},
|
|
"paused": {"identity": "issue:r:42:", "elapsed_ms": 5000, "running": False},
|
|
"returned": {"identity": "issue:r:42:", "resumed": True, "reason": "insights"},
|
|
"resumed": {"identity": "issue:r:42:", "elapsed_ms": 7000, "running": True},
|
|
}
|
|
|
|
|
|
def test_mobile_detour_never_resumes_manually_paused_or_replaced_work():
|
|
script = SOURCE.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:()=>1000});
|
|
timer.adopt('issue:r:1:', 3000, false);
|
|
const pausedDetour = timer.beginDetour('queues');
|
|
timer.activate('issue:r:1:');
|
|
const runningDetour = timer.beginDetour('queues');
|
|
timer.activate('issue:r:2:');
|
|
const replacedReturn = timer.returnFromDetour();
|
|
process.stdout.write(JSON.stringify({pausedDetour, runningDetour, replacedReturn, snapshot:timer.snapshot()}));
|
|
"""
|
|
completed = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
|
|
|
assert completed.returncode == 0, completed.stderr
|
|
assert json.loads(completed.stdout) == {
|
|
"pausedDetour": None,
|
|
"runningDetour": {"identity": "issue:r:1:", "resume": True, "reason": "queues"},
|
|
"replacedReturn": None,
|
|
"snapshot": {"identity": "issue:r:2:", "elapsed_ms": 0, "running": True},
|
|
}
|
|
|
|
|
|
def test_device_setup_detour_excludes_permission_time_and_survives_reload():
|
|
script = SOURCE.read_text() + r"""
|
|
const values = new Map();
|
|
const storage = {getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value)};
|
|
let now = 1000;
|
|
let timer = createTodayTimer({storage, getLogin:()=> 'timmy', now:()=>now});
|
|
timer.activate('issue:r:42:');
|
|
now = 6000;
|
|
const interruption = timer.beginDetour('device-setup');
|
|
now = 26000;
|
|
timer = createTodayTimer({storage, getLogin:()=> 'timmy', now:()=>now});
|
|
const restored = timer.detourInterruption();
|
|
const paused = timer.snapshot();
|
|
const returned = timer.returnFromDetour();
|
|
now = 28000;
|
|
process.stdout.write(JSON.stringify({interruption, restored, paused, returned, resumed:timer.snapshot()}));
|
|
"""
|
|
completed = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
|
|
|
assert completed.returncode == 0, completed.stderr
|
|
assert json.loads(completed.stdout) == {
|
|
"interruption": {"identity": "issue:r:42:", "resume": True, "reason": "device-setup"},
|
|
"restored": {"identity": "issue:r:42:", "resume": True, "reason": "device-setup"},
|
|
"paused": {"identity": "issue:r:42:", "elapsed_ms": 5000, "running": False},
|
|
"returned": {"identity": "issue:r:42:", "resumed": True, "reason": "device-setup"},
|
|
"resumed": {"identity": "issue:r:42:", "elapsed_ms": 7000, "running": True},
|
|
}
|
|
|
|
|
|
def test_mobile_detour_view_names_work_and_returns_to_today():
|
|
script = SOURCE.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:()=>1000});
|
|
const banner = {hidden:true};
|
|
const label = {textContent:''};
|
|
const button = {listeners:{}, addEventListener(name, callback) { this.listeners[name] = callback; }, click() { this.listeners.click(); }};
|
|
const returns = [];
|
|
const view = createTodayTimer.createDetourInterruption({
|
|
timer,
|
|
queryAll:selector => ({
|
|
'[data-today-detour]':[banner],
|
|
'[data-today-detour-label]':[label],
|
|
'[data-return-from-detour]':[button],
|
|
}[selector] || []),
|
|
getItemLabel:identity=>identity === 'issue:r:1:' ? 'Ship release' : '',
|
|
onReturn:identity=>returns.push(identity),
|
|
});
|
|
timer.activate('issue:r:1:');
|
|
const opened = view.open('find');
|
|
const shown = {hidden:banner.hidden, text:label.textContent, timer:timer.snapshot()};
|
|
button.click();
|
|
process.stdout.write(JSON.stringify({opened, shown, closed:{hidden:banner.hidden, timer:timer.snapshot(), returns}}));
|
|
"""
|
|
completed = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
|
|
|
assert completed.returncode == 0, completed.stderr
|
|
assert json.loads(completed.stdout) == {
|
|
"opened": {"identity": "issue:r:1:", "resume": True, "reason": "find"},
|
|
"shown": {
|
|
"hidden": False,
|
|
"text": "Today paused · Ship release",
|
|
"timer": {"identity": "issue:r:1:", "elapsed_ms": 0, "running": False},
|
|
},
|
|
"closed": {
|
|
"hidden": True,
|
|
"timer": {"identity": "issue:r:1:", "elapsed_ms": 0, "running": True},
|
|
"returns": ["issue:r:1:"],
|
|
},
|
|
}
|