feat: pause Today during mobile Search (Closes #1078)
This commit is contained in:
parent
ff34416c3c
commit
d4910b4398
|
|
@ -137,6 +137,9 @@ textarea { resize: vertical; min-height: 120px; }
|
||||||
#cmd-palette.open { display: block; }
|
#cmd-palette.open { display: block; }
|
||||||
.cmd-palette-header { display:none; align-items:center; justify-content:space-between; gap:10px; }
|
.cmd-palette-header { display:none; align-items:center; justify-content:space-between; gap:10px; }
|
||||||
.cmd-palette-header-actions { display:flex; gap:8px; }
|
.cmd-palette-header-actions { display:flex; gap:8px; }
|
||||||
|
.search-today-interruption { display:flex; align-items:center; justify-content:space-between; gap:10px; margin:8px 0; padding:8px 10px; border:1px solid #2a496e; border-radius:10px; background:#10213a; }
|
||||||
|
.search-today-interruption[hidden] { display:none; }
|
||||||
|
.search-today-interruption button { min-height:44px; flex:0 0 auto; }
|
||||||
.cmd-search-scope { display:grid; grid-template-columns:auto minmax(120px,1fr) auto minmax(120px,1fr); gap:6px 10px; align-items:center; margin:8px 0 0; padding:8px; border:1px solid #1f3a5f; border-radius:8px; }
|
.cmd-search-scope { display:grid; grid-template-columns:auto minmax(120px,1fr) auto minmax(120px,1fr); gap:6px 10px; align-items:center; margin:8px 0 0; padding:8px; border:1px solid #1f3a5f; border-radius:8px; }
|
||||||
.cmd-search-scope legend { padding:0 4px; color:#93a4b8; font-size:12px; }
|
.cmd-search-scope legend { padding:0 4px; color:#93a4b8; font-size:12px; }
|
||||||
.cmd-search-scope label { font-size:12px; color:#cbd5e1; }
|
.cmd-search-scope label { font-size:12px; color:#cbd5e1; }
|
||||||
|
|
|
||||||
|
|
@ -1839,6 +1839,7 @@
|
||||||
formatEstimate: formatPlanMinutes,
|
formatEstimate: formatPlanMinutes,
|
||||||
getItem: identity => [...todayMyWork, ...activeMyWork].find(item => todayWork.identity(item) === identity),
|
getItem: identity => [...todayMyWork, ...activeMyWork].find(item => todayWork.identity(item) === identity),
|
||||||
onReopen: identity => {
|
onReopen: identity => {
|
||||||
|
taskOverlayHistory.leave();
|
||||||
selectTodayWork();
|
selectTodayWork();
|
||||||
workSession.reopen(todayMyWork.find(item => todayWork.identity(item) === identity));
|
workSession.reopen(todayMyWork.find(item => todayWork.identity(item) === identity));
|
||||||
},
|
},
|
||||||
|
|
@ -5433,9 +5434,9 @@
|
||||||
if (shareStatus[state.status]) status.textContent = shareStatus[state.status];
|
if (shareStatus[state.status]) status.textContent = shareStatus[state.status];
|
||||||
else if (state.status === 'reopening') status.textContent = 'Reopening…';
|
else if (state.status === 'reopening') status.textContent = 'Reopening…';
|
||||||
else if (state.status === 'claiming') status.textContent = 'Assigning this issue to you…';
|
else if (state.status === 'claiming') status.textContent = 'Assigning this issue to you…';
|
||||||
else if (state.status === 'claimed') status.textContent = 'Assignment confirmed. Opening My Work…';
|
else if (state.status === 'claimed') status.textContent = 'Assigned. Opening My Work…';
|
||||||
else if (detail.claimable) status.textContent = 'This issue is open and unassigned.';
|
else if (detail.claimable) status.textContent = 'Open and unassigned.';
|
||||||
else if (detail.assigned_to_me) status.textContent = 'This item is already in My Work.';
|
else if (detail.assigned_to_me) status.textContent = 'Already in My Work.';
|
||||||
else if (detail.reopenable) status.textContent = 'Closed—reopen to resume.';
|
else if (detail.reopenable) status.textContent = 'Closed—reopen to resume.';
|
||||||
else status.textContent = 'Ready.';
|
else status.textContent = 'Ready.';
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -521,6 +521,10 @@
|
||||||
<button id="close-command-palette" type="button">Close</button>
|
<button id="close-command-palette" type="button">Close</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<aside class="search-today-interruption" data-search-today-interruption role="status" aria-live="polite" hidden>
|
||||||
|
<strong data-search-today-label>Today paused</strong>
|
||||||
|
<button data-return-from-search type="button">Return to Today</button>
|
||||||
|
</aside>
|
||||||
<input id="cmd-input" type="text" role="combobox" aria-autocomplete="list" aria-controls="cmd-results" aria-expanded="false" placeholder="Search commands, issues, and pull requests..." />
|
<input id="cmd-input" type="text" role="combobox" aria-autocomplete="list" aria-controls="cmd-results" aria-expanded="false" placeholder="Search commands, issues, and pull requests..." />
|
||||||
<fieldset class="cmd-search-scope">
|
<fieldset class="cmd-search-scope">
|
||||||
<legend>Filter search results</legend>
|
<legend>Filter search results</legend>
|
||||||
|
|
@ -598,6 +602,10 @@
|
||||||
</div>
|
</div>
|
||||||
<button id="close-search-preview" type="button">Back to search</button>
|
<button id="close-search-preview" type="button">Back to search</button>
|
||||||
</div>
|
</div>
|
||||||
|
<aside class="search-today-interruption" data-search-today-interruption role="status" aria-live="polite" hidden>
|
||||||
|
<strong data-search-today-label>Today paused</strong>
|
||||||
|
<button data-return-from-search type="button">Return to Today</button>
|
||||||
|
</aside>
|
||||||
<nav class="mobile-search-preview-nav" aria-label="Search preview sections">
|
<nav class="mobile-search-preview-nav" aria-label="Search preview sections">
|
||||||
<button type="button" data-search-preview-section="overview">Overview</button>
|
<button type="button" data-search-preview-section="overview">Overview</button>
|
||||||
<button type="button" data-search-preview-section="conversation">Conversation</button>
|
<button type="button" data-search-preview-section="conversation">Conversation</button>
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ function createTodayTimer({ storage, getLogin, now = () => Date.now(), onChange
|
||||||
};
|
};
|
||||||
const empty = () => ({
|
const empty = () => ({
|
||||||
version:1, active_identity:'', entries:{}, away_at:null,
|
version:1, active_identity:'', entries:{}, away_at:null,
|
||||||
pending_interruption:null, attention_interruption:null, capture_interruption:null, timed_break:null,
|
pending_interruption:null, attention_interruption:null, capture_interruption:null, search_interruption:null, timed_break:null,
|
||||||
});
|
});
|
||||||
const read = () => {
|
const read = () => {
|
||||||
const ownerKey = key();
|
const ownerKey = key();
|
||||||
|
|
@ -48,6 +48,12 @@ function createTodayTimer({ storage, getLogin, now = () => Date.now(), onChange
|
||||||
typeof pending.resume === 'boolean' ?
|
typeof pending.resume === 'boolean' ?
|
||||||
{ identity:pending.identity, resume:pending.resume } : null;
|
{ identity:pending.identity, resume:pending.resume } : null;
|
||||||
};
|
};
|
||||||
|
const validSearch = state => {
|
||||||
|
const pending = state.search_interruption;
|
||||||
|
return pending && typeof pending.identity === 'string' && pending.identity &&
|
||||||
|
typeof pending.resume === 'boolean' ?
|
||||||
|
{ identity:pending.identity, resume:pending.resume } : null;
|
||||||
|
};
|
||||||
const validBreak = state => {
|
const validBreak = state => {
|
||||||
const value = state.timed_break;
|
const value = state.timed_break;
|
||||||
return value && typeof value.identity === 'string' && value.identity &&
|
return value && typeof value.identity === 'string' && value.identity &&
|
||||||
|
|
@ -94,6 +100,7 @@ function createTodayTimer({ storage, getLogin, now = () => Date.now(), onChange
|
||||||
state.away_at = null;
|
state.away_at = null;
|
||||||
state.pending_interruption = null;
|
state.pending_interruption = null;
|
||||||
state.attention_interruption = null;
|
state.attention_interruption = null;
|
||||||
|
state.search_interruption = null;
|
||||||
state.timed_break = null;
|
state.timed_break = null;
|
||||||
return write(state);
|
return write(state);
|
||||||
},
|
},
|
||||||
|
|
@ -110,6 +117,7 @@ function createTodayTimer({ storage, getLogin, now = () => Date.now(), onChange
|
||||||
state.away_at = null;
|
state.away_at = null;
|
||||||
state.pending_interruption = null;
|
state.pending_interruption = null;
|
||||||
state.attention_interruption = null;
|
state.attention_interruption = null;
|
||||||
|
state.search_interruption = null;
|
||||||
state.timed_break = null;
|
state.timed_break = null;
|
||||||
return write(state);
|
return write(state);
|
||||||
},
|
},
|
||||||
|
|
@ -161,6 +169,7 @@ function createTodayTimer({ storage, getLogin, now = () => Date.now(), onChange
|
||||||
state.away_at = null;
|
state.away_at = null;
|
||||||
state.pending_interruption = null;
|
state.pending_interruption = null;
|
||||||
state.attention_interruption = null;
|
state.attention_interruption = null;
|
||||||
|
state.search_interruption = null;
|
||||||
state.timed_break = null;
|
state.timed_break = null;
|
||||||
return write(state);
|
return write(state);
|
||||||
},
|
},
|
||||||
|
|
@ -232,6 +241,44 @@ function createTodayTimer({ storage, getLogin, now = () => Date.now(), onChange
|
||||||
state.away_at = null;
|
state.away_at = null;
|
||||||
return write(state) ? { identity:pending.identity, resumed } : null;
|
return write(state) ? { identity:pending.identity, resumed } : null;
|
||||||
},
|
},
|
||||||
|
beginSearch() {
|
||||||
|
const state = read();
|
||||||
|
const existing = validSearch(state);
|
||||||
|
if (existing) return existing;
|
||||||
|
const identity = state.active_identity;
|
||||||
|
const entry = state.entries[identity];
|
||||||
|
if (!identity || !entry?.running) return null;
|
||||||
|
const resume = true;
|
||||||
|
settle(state);
|
||||||
|
state.away_at = null;
|
||||||
|
state.search_interruption = { identity, resume };
|
||||||
|
return write(state) ? { ...state.search_interruption } : null;
|
||||||
|
},
|
||||||
|
searchInterruption() {
|
||||||
|
return validSearch(read());
|
||||||
|
},
|
||||||
|
abandonSearch() {
|
||||||
|
const state = read();
|
||||||
|
const pending = validSearch(state);
|
||||||
|
if (!pending) return null;
|
||||||
|
state.search_interruption = null;
|
||||||
|
state.away_at = null;
|
||||||
|
return write(state) ? { identity:pending.identity, resumed:false } : null;
|
||||||
|
},
|
||||||
|
returnFromSearch() {
|
||||||
|
const state = read();
|
||||||
|
const pending = validSearch(state);
|
||||||
|
const entry = pending && state.entries[pending.identity];
|
||||||
|
if (!pending || !entry) return null;
|
||||||
|
const resumed = pending.resume && state.active_identity === pending.identity;
|
||||||
|
if (resumed && !entry.running) {
|
||||||
|
entry.started_at = now();
|
||||||
|
entry.running = true;
|
||||||
|
}
|
||||||
|
state.search_interruption = null;
|
||||||
|
state.away_at = null;
|
||||||
|
return write(state) ? { identity:pending.identity, resumed } : null;
|
||||||
|
},
|
||||||
markAway() {
|
markAway() {
|
||||||
const state = read();
|
const state = read();
|
||||||
const entry = state.entries[state.active_identity];
|
const entry = state.entries[state.active_identity];
|
||||||
|
|
@ -317,6 +364,10 @@ function createTodayTimerView({ timer, isActive, queryAll, formatEstimate, getRu
|
||||||
getItem,
|
getItem,
|
||||||
onReturn:() => render(),
|
onReturn:() => render(),
|
||||||
});
|
});
|
||||||
|
const searchView = createTodaySearchInterruption({
|
||||||
|
timer, queryAll, getItemLabel:identity => getItem?.(identity)?.title, onChange:() => render(),
|
||||||
|
onReturn:identity => onReopen?.(identity),
|
||||||
|
});
|
||||||
queryAll('[data-mobile-today-open]').forEach(button =>
|
queryAll('[data-mobile-today-open]').forEach(button =>
|
||||||
button.addEventListener('click', () => {
|
button.addEventListener('click', () => {
|
||||||
const identity = timer.snapshot().identity;
|
const identity = timer.snapshot().identity;
|
||||||
|
|
@ -424,6 +475,8 @@ function createTodayTimerView({ timer, isActive, queryAll, formatEstimate, getRu
|
||||||
else if (!active) timer.stop();
|
else if (!active) timer.stop();
|
||||||
render();
|
render();
|
||||||
},
|
},
|
||||||
|
reopen(identity) { onReopen?.(identity); },
|
||||||
|
search(action, ...args) { return searchView[action]?.(...args); },
|
||||||
finish() { timer.stop(); progress = null; runway = null; render(); },
|
finish() { timer.stop(); progress = null; runway = null; render(); },
|
||||||
update(nextProgress, nextRunway) { progress = nextProgress; runway = nextRunway; render(); },
|
update(nextProgress, nextRunway) { progress = nextProgress; runway = nextRunway; render(); },
|
||||||
reset() { progress = null; runway = null; render(); },
|
reset() { progress = null; runway = null; render(); },
|
||||||
|
|
@ -449,6 +502,7 @@ function createTodayTimerView({ timer, isActive, queryAll, formatEstimate, getRu
|
||||||
onCapture?.();
|
onCapture?.();
|
||||||
}));
|
}));
|
||||||
if (timer.captureInterruption) view.restoreCapture();
|
if (timer.captureInterruption) view.restoreCapture();
|
||||||
|
searchView.restore();
|
||||||
return view;
|
return view;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -501,6 +555,55 @@ function createTodayInterruptionPrompt({ timer, sheet, description, getItemLabel
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createTodaySearchInterruption({ timer, queryAll, getItemLabel, onChange, onReturn }) {
|
||||||
|
const render = (pending = timer.searchInterruption?.()) => {
|
||||||
|
queryAll('[data-search-today-interruption]').forEach(element => { element.hidden = !pending; });
|
||||||
|
queryAll('[data-search-today-label]').forEach(element => {
|
||||||
|
element.textContent = pending ? 'Today paused · ' +
|
||||||
|
String(getItemLabel?.(pending.identity) || 'Current Today item') : '';
|
||||||
|
});
|
||||||
|
onChange?.();
|
||||||
|
return pending;
|
||||||
|
};
|
||||||
|
const view = {
|
||||||
|
open() {
|
||||||
|
const wasRunning = timer.snapshot().running;
|
||||||
|
const pending = timer.beginSearch?.();
|
||||||
|
render(pending || timer.searchInterruption?.());
|
||||||
|
if (wasRunning && !pending) {
|
||||||
|
const status = queryAll('#cmd-search-action-status')[0];
|
||||||
|
if (status) status.textContent = 'Search is open, but Today timing could not be paused on this device.';
|
||||||
|
}
|
||||||
|
return pending;
|
||||||
|
},
|
||||||
|
restore() { return render(); },
|
||||||
|
finish() {
|
||||||
|
const result = timer.returnFromSearch?.();
|
||||||
|
render(null);
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
transfer() {
|
||||||
|
const result = timer.abandonSearch?.();
|
||||||
|
render(null);
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
queryAll('#open-palette').forEach(button => button.addEventListener('click', () => view.open()));
|
||||||
|
queryAll('[data-return-from-search]').forEach(button => button.addEventListener('click', () => {
|
||||||
|
const pending = timer.searchInterruption?.();
|
||||||
|
view.finish();
|
||||||
|
if (pending) onReturn?.(pending.identity);
|
||||||
|
}));
|
||||||
|
if (typeof MutationObserver !== 'undefined') {
|
||||||
|
const overlays = [...queryAll('#cmd-palette'), ...queryAll('#search-preview')];
|
||||||
|
const observer = new MutationObserver(() => {
|
||||||
|
if (overlays.every(element => !element.classList.contains('open'))) view.finish();
|
||||||
|
});
|
||||||
|
overlays.forEach(element => observer.observe(element, {attributes:true, attributeFilter:['class']}));
|
||||||
|
}
|
||||||
|
return view;
|
||||||
|
}
|
||||||
|
|
||||||
function createTodayCaptureInterruption({ timer, banner, label, getItem, getItemLabel, onReturn }) {
|
function createTodayCaptureInterruption({ timer, banner, label, getItem, getItemLabel, onReturn }) {
|
||||||
banner ||= typeof document === 'undefined' ? null : document.querySelector('#today-capture-interruption');
|
banner ||= typeof document === 'undefined' ? null : document.querySelector('#today-capture-interruption');
|
||||||
label ||= typeof document === 'undefined' ? null : document.querySelector('#today-capture-interruption-label');
|
label ||= typeof document === 'undefined' ? null : document.querySelector('#today-capture-interruption-label');
|
||||||
|
|
@ -539,6 +642,7 @@ if (typeof module !== 'undefined' && module.exports) {
|
||||||
createTodayTimer.createView = createTodayTimerView;
|
createTodayTimer.createView = createTodayTimerView;
|
||||||
createTodayTimer.createInterruptionPrompt = createTodayInterruptionPrompt;
|
createTodayTimer.createInterruptionPrompt = createTodayInterruptionPrompt;
|
||||||
createTodayTimer.createBudgetReplan = createTodayBudgetReplan;
|
createTodayTimer.createBudgetReplan = createTodayBudgetReplan;
|
||||||
|
createTodayTimer.createSearchInterruption = createTodaySearchInterruption;
|
||||||
createTodayTimer.createCaptureInterruption = createTodayCaptureInterruption;
|
createTodayTimer.createCaptureInterruption = createTodayCaptureInterruption;
|
||||||
module.exports = createTodayTimer;
|
module.exports = createTodayTimer;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -120,6 +120,30 @@ def test_release_artifact_plans_hands_off_and_opens_next_mobile_issue(tmp_path:
|
||||||
assert resumed == {"running": True, "saved": True}
|
assert resumed == {"running": True, "saved": True}
|
||||||
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
|
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
|
||||||
|
|
||||||
|
page.locator('[data-mobile-task="search"]').click()
|
||||||
|
search_pause = page.locator('#cmd-palette [data-search-today-interruption]')
|
||||||
|
expect(search_pause).to_be_visible()
|
||||||
|
expect(search_pause).to_contain_text("Today paused · Ship mobile capture")
|
||||||
|
return_button = search_pause.locator('[data-return-from-search]')
|
||||||
|
bounds = return_button.bounding_box()
|
||||||
|
assert bounds and bounds["height"] >= 44
|
||||||
|
assert page.evaluate("""() => {
|
||||||
|
const key = Object.keys(localStorage).find(value => value.startsWith('stackchain.today-timer.v1.'));
|
||||||
|
const timer = JSON.parse(localStorage.getItem(key));
|
||||||
|
return timer.entries[timer.active_identity].running === false &&
|
||||||
|
timer.search_interruption?.identity === timer.active_identity;
|
||||||
|
}""")
|
||||||
|
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
|
||||||
|
return_button.click()
|
||||||
|
expect(page.locator("#cmd-palette")).not_to_have_class("open")
|
||||||
|
expect(page.locator("#issue-sheet")).to_have_class("issue-sheet open")
|
||||||
|
assert page.evaluate("""() => {
|
||||||
|
const key = Object.keys(localStorage).find(value => value.startsWith('stackchain.today-timer.v1.'));
|
||||||
|
const timer = JSON.parse(localStorage.getItem(key));
|
||||||
|
return timer.entries[timer.active_identity].running === true && !timer.search_interruption;
|
||||||
|
}""")
|
||||||
|
page.locator("#close-issue-sheet").click()
|
||||||
|
|
||||||
page.locator("[data-mobile-today-more]").click()
|
page.locator("[data-mobile-today-more]").click()
|
||||||
expect(page.locator("#mobile-today-actions")).to_be_visible()
|
expect(page.locator("#mobile-today-actions")).to_be_visible()
|
||||||
blocked = page.locator("[data-mobile-today-blocked]")
|
blocked = page.locator("[data-mobile-today-blocked]")
|
||||||
|
|
|
||||||
|
|
@ -951,6 +951,17 @@ def test_search_preview_offers_assign_and_start_for_eligible_issues():
|
||||||
assert "grid-template-columns:repeat(2,minmax(0,1fr))" in css
|
assert "grid-template-columns:repeat(2,minmax(0,1fr))" in css
|
||||||
|
|
||||||
|
|
||||||
|
def test_mobile_search_pauses_today_and_offers_a_lossless_return():
|
||||||
|
html = dashboard_bundle_text()
|
||||||
|
css = (FRONTEND / "dashboard.css").read_text()
|
||||||
|
|
||||||
|
assert html.count('<aside class="search-today-interruption" data-search-today-interruption') == 2
|
||||||
|
assert html.count('<button data-return-from-search type="button">') == 2
|
||||||
|
assert "taskOverlayHistory.leave();" in html
|
||||||
|
assert ".search-today-interruption" in css
|
||||||
|
assert ".search-today-interruption button { min-height:44px;" in css
|
||||||
|
|
||||||
|
|
||||||
def test_search_preview_queues_eligible_issue_and_continues_preserved_search():
|
def test_search_preview_queues_eligible_issue_and_continues_preserved_search():
|
||||||
html = dashboard_bundle_text()
|
html = dashboard_bundle_text()
|
||||||
css = (FRONTEND / "dashboard.css").read_text()
|
css = (FRONTEND / "dashboard.css").read_text()
|
||||||
|
|
|
||||||
|
|
@ -173,3 +173,57 @@ process.stdout.write(JSON.stringify({opened, shown, finished, closed, transferre
|
||||||
"transferred": {"identity": "issue:r:1:", "resumed": False},
|
"transferred": {"identity": "issue:r:1:", "resumed": False},
|
||||||
"final": {"identity": "issue:r:1:", "elapsed_ms": 0, "running": 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,
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user