feat: pause Today during quick capture (Closes #1076)
This commit is contained in:
1 parent
d9749e3a6f
commit
f8c2541d30
7 files changed
+304
-7
No files matched your search
@@ -94,6 +94,32 @@ def test_release_artifact_plans_hands_off_and_opens_next_mobile_issue(tmp_path:
|
||||
if page.locator("#plan-today-sheet").is_visible():
|
||||
page.locator("#cancel-plan-today").click()
|
||||
expect(page.locator("[data-mobile-today-hud]")).to_be_visible()
|
||||
|
||||
page.locator('[data-mobile-task="new"]').click()
|
||||
expect(page.locator("#create-issue-sheet")).to_have_class("create-issue-sheet open")
|
||||
expect(page.locator("#today-capture-interruption")).to_be_visible()
|
||||
expect(page.locator("#today-capture-interruption-label")).to_contain_text("Ship mobile capture")
|
||||
expect(page.locator("#save-unfiled-issue")).to_have_text("Save & return to Today")
|
||||
paused_timer = page.evaluate("""() => {
|
||||
const key = Object.keys(localStorage).find(value => value.startsWith('stackchain.today-timer.v1.'));
|
||||
const state = JSON.parse(localStorage.getItem(key));
|
||||
return state.entries[state.active_identity];
|
||||
}""")
|
||||
assert paused_timer["running"] is False
|
||||
page.locator("#create-issue-title").fill("Remember release note")
|
||||
page.locator("#save-unfiled-issue").click()
|
||||
expect(page.locator("#create-issue-sheet")).not_to_have_class("create-issue-sheet open")
|
||||
expect(page.locator("[data-mobile-today-hud]")).to_be_visible()
|
||||
resumed = page.evaluate("""() => {
|
||||
const key = Object.keys(localStorage).find(value => value.startsWith('stackchain.today-timer.v1.'));
|
||||
const timer = JSON.parse(localStorage.getItem(key));
|
||||
const captures = JSON.parse(localStorage.getItem('stackchain.unfiled-issues.v1') || 'null');
|
||||
return {running:timer.entries[timer.active_identity].running,
|
||||
saved:JSON.stringify(captures).includes('Remember release note')};
|
||||
}""")
|
||||
assert resumed == {"running": True, "saved": True}
|
||||
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
|
||||
|
||||
page.locator("[data-mobile-today-more]").click()
|
||||
expect(page.locator("#mobile-today-actions")).to_be_visible()
|
||||
blocked = page.locator("[data-mobile-today-blocked]")
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).parents[1]
|
||||
DASHBOARD = ROOT / "frontend" / "dashboard.js"
|
||||
INDEX = ROOT / "frontend" / "index.html"
|
||||
TIMER = ROOT / "frontend" / "today-timer.js"
|
||||
|
||||
|
||||
def test_quick_capture_wires_timer_interruption_to_all_exit_paths():
|
||||
source = DASHBOARD.read_text()
|
||||
timer_source = TIMER.read_text()
|
||||
html = INDEX.read_text()
|
||||
|
||||
assert 'id="today-capture-interruption"' in html
|
||||
assert 'id="today-capture-interruption-label"' in html
|
||||
assert "view.beginCapture();" in timer_source
|
||||
assert "timerView.transferCapture()" in source
|
||||
assert "timerView.finishCapture()" in source
|
||||
assert "result ? 'Save & return to Today' : 'Save to Drafts'" in timer_source
|
||||
assert "if (!capture)" in source
|
||||
|
||||
|
||||
def test_capture_save_returns_only_after_durable_admission():
|
||||
source = DASHBOARD.read_text()
|
||||
|
||||
saved = source.index("const savedCapture = await unfiledCaptures.save(captureDraft);")
|
||||
returned = source.index("timerView.finishCapture()", saved)
|
||||
failure = source.index("} catch (error) {", saved)
|
||||
assert saved < returned < failure
|
||||
@@ -49,3 +49,127 @@ process.stdout.write(JSON.stringify(changes));
|
||||
{"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},
|
||||
}
|
||||
Reference in new issue
Block a user