feat: reconcile long Today interruptions (Closes #591)
This commit is contained in:
1 parent
10a8b2738b
commit
aaa9aef78b
5 files changed
+298
-1
No files matched your search
@@ -1976,6 +1976,159 @@ process.stdout.write(JSON.stringify({{
|
||||
}
|
||||
|
||||
|
||||
def test_today_timer_reconciles_long_away_time_once_and_keeps_short_switches_seamless():
|
||||
script = f"""
|
||||
const createTodayTimer = require({json.dumps(str(TODAY_TIMER))});
|
||||
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';
|
||||
let now = 0;
|
||||
const timer = createTodayTimer({{storage,getLogin:() => login,now:() => now}});
|
||||
timer.activate('issue:repo:591:');
|
||||
now = 60000;
|
||||
timer.markAway();
|
||||
now = 4 * 60000;
|
||||
const short = timer.reconcileInterruption();
|
||||
now = 5 * 60000;
|
||||
timer.markAway();
|
||||
now = 11 * 60000;
|
||||
const pending = timer.reconcileInterruption();
|
||||
now = 12 * 60000;
|
||||
const frozen = timer.snapshot();
|
||||
const restored = createTodayTimer({{storage,getLogin:() => login,now:() => now}}).pendingInterruption();
|
||||
const counted = timer.resolveInterruption('count');
|
||||
const afterCount = timer.snapshot();
|
||||
const duplicate = timer.resolveInterruption('count');
|
||||
now = 13 * 60000;
|
||||
timer.markAway();
|
||||
now = 19 * 60000;
|
||||
timer.reconcileInterruption();
|
||||
const excluded = timer.resolveInterruption('exclude');
|
||||
const afterExclude = timer.snapshot();
|
||||
login = 'alexander';
|
||||
const isolated = timer.pendingInterruption();
|
||||
process.stdout.write(JSON.stringify({{
|
||||
short,pending,frozen,restored,counted,afterCount,duplicate,excluded,afterExclude,isolated,
|
||||
}}));
|
||||
"""
|
||||
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert json.loads(result.stdout) == {
|
||||
"short": None,
|
||||
"pending": {
|
||||
"identity": "issue:repo:591:",
|
||||
"away_ms": 360000,
|
||||
},
|
||||
"frozen": {
|
||||
"identity": "issue:repo:591:",
|
||||
"elapsed_ms": 660000,
|
||||
"running": False,
|
||||
},
|
||||
"restored": {
|
||||
"identity": "issue:repo:591:",
|
||||
"away_ms": 360000,
|
||||
},
|
||||
"counted": True,
|
||||
"afterCount": {
|
||||
"identity": "issue:repo:591:",
|
||||
"elapsed_ms": 660000,
|
||||
"running": True,
|
||||
},
|
||||
"duplicate": False,
|
||||
"excluded": True,
|
||||
"afterExclude": {
|
||||
"identity": "issue:repo:591:",
|
||||
"elapsed_ms": 720000,
|
||||
"running": True,
|
||||
},
|
||||
"isolated": None,
|
||||
}
|
||||
|
||||
|
||||
def test_today_interruption_prompt_restores_and_resolves_the_pending_mobile_decision():
|
||||
script = f"""
|
||||
const createTodayTimer = require({json.dumps(str(TODAY_TIMER))});
|
||||
const values = new Map();
|
||||
const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}};
|
||||
let now = 0;
|
||||
const timer = createTodayTimer({{storage,getLogin:()=>'timmy',now:()=>now}});
|
||||
const sheet = {{hidden:true}};
|
||||
const description = {{textContent:''}};
|
||||
let resolved = 0;
|
||||
const prompt = createTodayTimer.createInterruptionPrompt({{
|
||||
timer, sheet, description,
|
||||
getItemLabel: identity => identity === 'issue:r:7:' ? 'Fix mobile timer' : '',
|
||||
onResolved: () => resolved++,
|
||||
}});
|
||||
timer.activate('issue:r:7:');
|
||||
now = 60000;
|
||||
prompt.background();
|
||||
now = 7 * 60000;
|
||||
const opened = prompt.foreground();
|
||||
const shown = {{hidden:sheet.hidden, text:description.textContent}};
|
||||
const restored = prompt.restore();
|
||||
const excluded = prompt.resolve('exclude');
|
||||
const closed = sheet.hidden;
|
||||
process.stdout.write(JSON.stringify({{opened,shown,restored,excluded,closed,resolved}}));
|
||||
"""
|
||||
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert json.loads(result.stdout) == {
|
||||
"opened": True,
|
||||
"shown": {
|
||||
"hidden": False,
|
||||
"text": "Fix mobile timer · away for 6 minutes",
|
||||
},
|
||||
"restored": True,
|
||||
"excluded": True,
|
||||
"closed": True,
|
||||
"resolved": 1,
|
||||
}
|
||||
|
||||
|
||||
def test_today_interruption_detection_is_idempotent_and_fails_closed_for_invalid_state():
|
||||
script = f"""
|
||||
const createTodayTimer = require({json.dumps(str(TODAY_TIMER))});
|
||||
const values = new Map();
|
||||
const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}};
|
||||
let now = 0;
|
||||
const timer = createTodayTimer({{storage,getLogin:()=>'timmy',now:()=>now}});
|
||||
timer.activate('issue:r:1:');
|
||||
now = 60000;
|
||||
timer.markAway();
|
||||
now = 120000;
|
||||
timer.markAway();
|
||||
now = 6 * 60000;
|
||||
const originalCheckpoint = timer.reconcileInterruption();
|
||||
timer.resolveInterruption('exclude');
|
||||
timer.pause();
|
||||
now = 20 * 60000;
|
||||
const paused = timer.markAway();
|
||||
const key = 'stackchain.today-timer.v1.timmy';
|
||||
const invalid = JSON.parse(values.get(key));
|
||||
invalid.pending_interruption = {{identity:7,away_ms:'bad'}};
|
||||
invalid.away_at = now + 1000;
|
||||
values.set(key, JSON.stringify(invalid));
|
||||
const malformed = timer.reconcileInterruption();
|
||||
process.stdout.write(JSON.stringify({{originalCheckpoint,paused,malformed,pending:timer.pendingInterruption()}}));
|
||||
"""
|
||||
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert json.loads(result.stdout) == {
|
||||
"originalCheckpoint": {"identity": "issue:r:1:", "away_ms": 300000},
|
||||
"paused": False,
|
||||
"malformed": None,
|
||||
"pending": None,
|
||||
}
|
||||
|
||||
|
||||
def test_today_timer_switches_items_retains_elapsed_time_and_is_account_bound():
|
||||
script = f"""
|
||||
const createTodayTimer = require({json.dumps(str(TODAY_TIMER))});
|
||||
@@ -2057,6 +2210,25 @@ async def test_today_timer_is_wired_into_every_mobile_session_control():
|
||||
assert '.work-session-nav [data-work-session-timer-toggle] { min-height:44px;' in html
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_dashboard_reconciles_long_today_interruptions_in_a_touch_safe_sheet():
|
||||
html = await dashboard()
|
||||
|
||||
assert 'id="today-interruption-sheet"' in html
|
||||
assert 'id="today-interruption-description"' in html
|
||||
assert 'data-today-interruption="count"' in html
|
||||
assert 'data-today-interruption="exclude"' in html
|
||||
assert "const interruptionPrompt = createTodayInterruptionPrompt({" in html
|
||||
assert "if (document.hidden) interruptionPrompt.background();" in html
|
||||
assert "else interruptionPrompt.foreground();" in html
|
||||
assert "window.addEventListener('pagehide', () => interruptionPrompt.background());" in html
|
||||
assert "interruptionPrompt.restore();" in html
|
||||
assert "interruptionPrompt.resolve(button.dataset.todayInterruption)" in html
|
||||
assert ".today-interruption-panel { box-sizing:border-box; width:min(620px,100%);" in html
|
||||
assert "padding-bottom:calc(18px + env(safe-area-inset-bottom))" in html
|
||||
assert ".today-interruption-actions button { min-height:44px; width:100%; }" in html
|
||||
|
||||
|
||||
def test_active_today_session_reopens_current_item_without_restarting_checkpoint():
|
||||
script = f"""
|
||||
const buildMyWork = require({json.dumps(str(MY_WORK))});
|
||||
|
||||
Reference in new issue
Block a user