Merge pull request 'Resume interrupted mobile Updates triage' (#748) from timmy/747-resumable-updates-triage into main
This commit is contained in:
commit
6c6cd3c71b
|
|
@ -383,6 +383,7 @@ textarea { resize: vertical; min-height: 120px; }
|
|||
.update-sheet-panel { width:min(560px,100%); height:100%; overflow:auto; padding:18px; background:#0b1526; border-left:1px solid #2a496e; }
|
||||
.update-sheet-header { display:flex; align-items:center; justify-content:space-between; gap:10px; }
|
||||
.update-sheet-header button { min-height:44px; }
|
||||
.update-triage-progress { margin:10px 0; padding:8px 10px; border:1px solid #2a496e; border-radius:10px; background:#10213a; }
|
||||
.update-sheet-content { overflow-wrap:anywhere; white-space:pre-wrap; }
|
||||
.update-reply { display:grid; gap:8px; margin-top:16px; }
|
||||
.update-reply textarea { width:100%; min-height:112px; resize:vertical; }
|
||||
|
|
|
|||
|
|
@ -73,6 +73,7 @@
|
|||
const mobileQueueLauncher = createMobileQueueLauncher({
|
||||
openToday: () => mobileWorkEntry.open(),
|
||||
openAgenda: openAgendaSession,
|
||||
openUpdates: openUpdateTriage,
|
||||
selectFilter: selectMobileQueue,
|
||||
firstAction: name => qs('#my-work-list .my-work-card-main, #my-work-list .draft-resume, #my-work-list .draft-continue, #my-work-list .draft-edit'),
|
||||
announce: message => { qs('#my-work-action-status').textContent = message; },
|
||||
|
|
@ -276,7 +277,10 @@
|
|||
document.addEventListener('visibilitychange', () => {
|
||||
if (document.hidden) interruptionPrompt.background();
|
||||
else interruptionPrompt.foreground();
|
||||
if (!document.hidden) refreshMyWorkView();
|
||||
if (!document.hidden) {
|
||||
refreshMyWorkView();
|
||||
if (updateTriage.active()) updateTriage.reconcile();
|
||||
}
|
||||
});
|
||||
|
||||
async function fetchReviewJson(url, options) {
|
||||
|
|
@ -969,6 +973,30 @@
|
|||
onClose: () => closeUpdateSheet(false),
|
||||
});
|
||||
|
||||
const updateTriage = createUpdateTriageSession({
|
||||
storage: localStorage,
|
||||
getLogin: () => confirmedOwnerLogin,
|
||||
getItems: () => lastMyWork.filter(item => item?.has_update && Number.isInteger(item.notification_id)),
|
||||
onOpen: item => notificationReader.open(
|
||||
item,
|
||||
offlineWorkMode ? offlineWorkStore.loadDetail(confirmedOwnerLogin, item) : null
|
||||
),
|
||||
onProgress: state => {
|
||||
const progress = qs('#update-triage-progress');
|
||||
progress.hidden = false;
|
||||
progress.textContent = 'Update ' + state.index + ' of ' + state.total;
|
||||
},
|
||||
onFinish: () => {
|
||||
qs('#update-triage-progress').hidden = true;
|
||||
showMobileQueueCompletion('Updates');
|
||||
},
|
||||
});
|
||||
|
||||
function openUpdateTriage() {
|
||||
selectMobileQueue('update');
|
||||
return updateTriage.resumable() ? updateTriage.resume() : updateTriage.start();
|
||||
}
|
||||
|
||||
function routedWorkItem(item) {
|
||||
if (item?.has_update && item.kind === 'update') {
|
||||
return { ...item, kind:'update' };
|
||||
|
|
@ -5444,7 +5472,10 @@
|
|||
button.focus();
|
||||
}
|
||||
});
|
||||
qs('#keep-update-unread').addEventListener('click', () => closeUpdateSheet(true));
|
||||
qs('#keep-update-unread').addEventListener('click', () => {
|
||||
if (updateTriage.active()) updateTriage.keepUnreadAndNext();
|
||||
else closeUpdateSheet(true);
|
||||
});
|
||||
qs('#update-ownership-action').addEventListener('click', () => updateOwnership.act());
|
||||
qs('#update-ownership-start').addEventListener('click', () => updateOwnership.start());
|
||||
qs('#retry-update-load').addEventListener('click', () => {
|
||||
|
|
@ -5516,6 +5547,7 @@
|
|||
'Reply and read acknowledgement queued for sync.';
|
||||
if (result?.delivery === 'posted' && result?.next) {
|
||||
notificationUndo.offer(item, result.next.items);
|
||||
if (updateTriage.active()) updateTriage.acceptCompleted();
|
||||
}
|
||||
} catch (error) {
|
||||
qs('#update-reply-status').textContent = error.message + ' Your draft is safe; retry.';
|
||||
|
|
@ -5530,7 +5562,10 @@
|
|||
qs('#mark-update-read-next').disabled = true;
|
||||
try {
|
||||
const result = await notificationReader.markReadAndNext(lastMyWork);
|
||||
if (result) notificationUndo.offer(result.item, result.items);
|
||||
if (result) {
|
||||
notificationUndo.offer(result.item, result.items);
|
||||
if (updateTriage.active()) updateTriage.acceptCompleted();
|
||||
}
|
||||
} finally {
|
||||
qs('#mark-update-read-next').disabled = false;
|
||||
}
|
||||
|
|
@ -5540,7 +5575,10 @@
|
|||
button.disabled = true;
|
||||
try {
|
||||
const result = await notificationReader.acknowledgeAndNext(lastMyWork);
|
||||
if (result) notificationUndo.offer(result.item, result.items);
|
||||
if (result) {
|
||||
notificationUndo.offer(result.item, result.items);
|
||||
if (updateTriage.active()) updateTriage.acceptCompleted();
|
||||
}
|
||||
} finally {
|
||||
button.disabled = offlineWorkMode;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -749,8 +749,9 @@
|
|||
<div class="small" id="update-sheet-key"></div>
|
||||
<h3 id="update-sheet-title">Unread update</h3>
|
||||
</div>
|
||||
<button id="keep-update-unread" type="button">Keep unread</button>
|
||||
<button id="keep-update-unread" type="button">Keep unread & next</button>
|
||||
</div>
|
||||
<div id="update-triage-progress" class="update-triage-progress small" aria-live="polite" hidden></div>
|
||||
<div id="update-sheet-status" class="small" aria-live="polite">Choose an update.</div>
|
||||
<button class="update-retry" id="retry-update-load" type="button" hidden>Retry loading update</button>
|
||||
<div class="row">
|
||||
|
|
@ -1071,6 +1072,7 @@
|
|||
<script src="static/mobile-task-dock.js"></script>
|
||||
<script src="static/mobile-work-entry.js"></script>
|
||||
<script src="static/mobile-queue-launcher.js"></script>
|
||||
<script src="static/update-triage-session.js"></script>
|
||||
<script src="static/agenda-session-launcher.js"></script>
|
||||
<script src="static/mobile-launch.js"></script>
|
||||
<script src="static/mobile-app-shortcuts.js"></script>
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@
|
|||
function open(name) {
|
||||
if (name === 'today') return options.openToday();
|
||||
if (name === 'agenda') return options.openAgenda();
|
||||
if (name === 'update' && options.openUpdates) return options.openUpdates();
|
||||
options.selectFilter(name);
|
||||
const action = options.firstAction(name);
|
||||
if (!action) {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
const BASE = new URL('./', self.location.href).pathname;
|
||||
importScripts(BASE + 'static/background-issue-sync.js');
|
||||
const CACHE = 'stackchain-dashboard-shell-v99';
|
||||
const CACHE = 'stackchain-dashboard-shell-v100';
|
||||
const OFFLINE_LEASE_URL = new URL(BASE + '__offline-session-lease', self.location.origin).href;
|
||||
const OUTAGE_STATUSES = new Set([500, 502, 503, 504]);
|
||||
const NAVIGATION_TIMEOUT_MS = self.__STACKCHAIN_NAVIGATION_TIMEOUT_MS || 4000;
|
||||
|
|
@ -76,6 +76,7 @@ const SHELL = [
|
|||
BASE + 'static/mobile-task-dock.js',
|
||||
BASE + 'static/mobile-work-entry.js',
|
||||
BASE + 'static/mobile-queue-launcher.js',
|
||||
BASE + 'static/update-triage-session.js',
|
||||
BASE + 'static/agenda-session-launcher.js',
|
||||
BASE + 'static/mobile-launch.js',
|
||||
BASE + 'static/mobile-app-shortcuts.js',
|
||||
|
|
|
|||
100
frontend/update-triage-session.js
Normal file
100
frontend/update-triage-session.js
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
(function (root, factory) {
|
||||
if (typeof module === 'object' && module.exports) module.exports = factory;
|
||||
else root.createUpdateTriageSession = factory;
|
||||
})(typeof self !== 'undefined' ? self : this, function createUpdateTriageSession(options) {
|
||||
const key = options.key || 'stackchain.update-triage.v1';
|
||||
const identity = item => Number.isInteger(item?.notification_id) ? String(item.notification_id) : '';
|
||||
const login = () => String(options.getLogin() || '').trim();
|
||||
let state = null;
|
||||
let running = false;
|
||||
|
||||
function read() {
|
||||
const owner = login();
|
||||
if (!owner) return null;
|
||||
try {
|
||||
const value = JSON.parse(options.storage.getItem(key) || 'null');
|
||||
if (value?.version !== 1 || value.login !== owner || !Array.isArray(value.identities) ||
|
||||
!value.identities.length || typeof value.current !== 'string' || !Array.isArray(value.completed)) return null;
|
||||
const identities = value.identities.filter(value => typeof value === 'string' && value);
|
||||
const completed = value.completed.filter(value => identities.includes(value));
|
||||
if (!identities.length || !identities.includes(value.current)) return null;
|
||||
return { version:1, login:owner, identities, current:value.current, completed };
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function persist() {
|
||||
if (!state) return;
|
||||
options.storage.setItem(key, JSON.stringify(state));
|
||||
}
|
||||
|
||||
function available() {
|
||||
const byIdentity = new Map((options.getItems() || []).map(item => [identity(item), item]));
|
||||
return state.identities.filter(id => !state.completed.includes(id) && byIdentity.has(id))
|
||||
.map(id => byIdentity.get(id));
|
||||
}
|
||||
|
||||
function finish() {
|
||||
running = false;
|
||||
state = null;
|
||||
options.storage.removeItem(key);
|
||||
options.onFinish();
|
||||
return false;
|
||||
}
|
||||
|
||||
function openCurrent(preferredIndex = null) {
|
||||
const items = available();
|
||||
if (!items.length) return finish();
|
||||
let index = items.findIndex(item => identity(item) === state.current);
|
||||
if (index < 0) index = Math.min(preferredIndex ?? 0, items.length - 1);
|
||||
state.current = identity(items[index]);
|
||||
persist();
|
||||
const originalIndex = state.identities.indexOf(state.current);
|
||||
options.onProgress({ index:originalIndex + 1, total:state.identities.length });
|
||||
options.onOpen(items[index]);
|
||||
return true;
|
||||
}
|
||||
|
||||
function advance(openNext = true) {
|
||||
if (!running || !state) return false;
|
||||
const originalIndex = state.identities.indexOf(state.current);
|
||||
if (!state.completed.includes(state.current)) state.completed.push(state.current);
|
||||
const candidates = available();
|
||||
const next = candidates.find(item => state.identities.indexOf(identity(item)) > originalIndex) || candidates[0];
|
||||
if (!next) return finish();
|
||||
state.current = identity(next);
|
||||
if (openNext) return openCurrent();
|
||||
persist();
|
||||
options.onProgress({ index:state.identities.indexOf(state.current) + 1, total:state.identities.length });
|
||||
return true;
|
||||
}
|
||||
|
||||
return {
|
||||
active: () => running,
|
||||
resumable: () => Boolean(read()),
|
||||
start() {
|
||||
const identities = (options.getItems() || []).map(identity).filter(Boolean);
|
||||
if (!identities.length) return finish();
|
||||
state = { version:1, login:login(), identities, current:identities[0], completed:[] };
|
||||
if (!state.login) return false;
|
||||
running = true;
|
||||
return openCurrent();
|
||||
},
|
||||
resume() {
|
||||
state = read();
|
||||
if (!state) return false;
|
||||
running = true;
|
||||
return openCurrent(state.identities.indexOf(state.current));
|
||||
},
|
||||
reconcile() {
|
||||
if (!running || !state) return false;
|
||||
return openCurrent(state.identities.indexOf(state.current));
|
||||
},
|
||||
completeAndNext: advance,
|
||||
acceptCompleted: () => advance(false),
|
||||
keepUnreadAndNext: advance,
|
||||
items: () => state ? available().slice() : [],
|
||||
end: finish,
|
||||
};
|
||||
});
|
||||
|
|
@ -28,7 +28,7 @@ FEATURE_SOURCES = {
|
|||
"device-setup": ("static/install-app.js", "static/mobile-device-setup.js"),
|
||||
"security-center": ("static/security-center.js",),
|
||||
"today-timer": (
|
||||
"static/my-work.js", "static/protect-today.js", "static/mobile-task-dock.js", "static/mobile-queue-launcher.js", "static/notification-undo.js", "static/today-timer.js", "static/today-recap.js",
|
||||
"static/my-work.js", "static/protect-today.js", "static/mobile-task-dock.js", "static/mobile-queue-launcher.js", "static/update-triage-session.js", "static/notification-undo.js", "static/today-timer.js", "static/today-recap.js",
|
||||
"static/today-rollover.js", "static/later-work.js", "static/drafts.js", "static/unfiled-captures.js",
|
||||
"static/draft-filing-session.js", "static/draft-capacity-dialog.js", "static/work-selection.js",
|
||||
"static/today-work.js", "static/pick-work.js", "static/batch-find-work.js",
|
||||
|
|
|
|||
|
|
@ -303,4 +303,4 @@ async def test_unread_update_offers_reply_mark_read_and_next_independent_of_toda
|
|||
assert '.update-reply-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr));' in html
|
||||
assert '.update-reply-actions button { min-height:44px;' in html
|
||||
worker = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
|
||||
assert "stackchain-dashboard-shell-v99" in worker
|
||||
assert "stackchain-dashboard-shell-v100" in worker
|
||||
|
|
|
|||
|
|
@ -187,7 +187,7 @@ def test_legacy_cache_marker_is_normalized_out_of_build_identity(tmp_path):
|
|||
worker = changed_frontend / "service-worker.js"
|
||||
worker.write_text(
|
||||
worker.read_text().replace(
|
||||
"const CACHE = 'stackchain-dashboard-shell-v99';",
|
||||
"const CACHE = 'stackchain-dashboard-shell-v100';",
|
||||
"const CACHE = 'stackchain-dashboard-shell-v999';",
|
||||
)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -347,5 +347,5 @@ async def test_dashboard_syncs_every_later_change_and_exposes_account_status():
|
|||
def test_later_sync_ships_atomically_in_the_offline_shell():
|
||||
source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v99" in source
|
||||
assert "stackchain-dashboard-shell-v100" in source
|
||||
assert "BASE + 'static/later-sync.js'" in source
|
||||
|
|
|
|||
|
|
@ -137,4 +137,4 @@ def test_markdown_work_bodies_are_mobile_safe_block_containers():
|
|||
assert ".markdown-content { min-width:0; max-width:100%; overflow-wrap:anywhere;" in css
|
||||
assert ".markdown-content pre { max-width:100%; overflow-x:auto;" in css
|
||||
assert ".markdown-content a { min-height:44px;" in css
|
||||
assert "stackchain-dashboard-shell-v99" in worker
|
||||
assert "stackchain-dashboard-shell-v100" in worker
|
||||
|
|
|
|||
|
|
@ -45,7 +45,7 @@ def test_offline_shell_contains_every_local_dashboard_runtime_asset():
|
|||
shell_assets = set(re.findall(r"BASE \+ '([^']+)'", worker.split("async function sessionCsrf", 1)[0]))
|
||||
|
||||
assert local_assets <= shell_assets, f"Offline shell is missing: {sorted(local_assets - shell_assets)}"
|
||||
assert "stackchain-dashboard-shell-v99" in worker
|
||||
assert "stackchain-dashboard-shell-v100" in worker
|
||||
|
||||
|
||||
def test_all_conversation_composers_offer_accessible_mobile_mentions():
|
||||
|
|
|
|||
|
|
@ -214,7 +214,7 @@ def test_mobile_dashboard_mounts_phone_safe_device_setup_flow():
|
|||
assert "promptStorage:localStorage" in dashboard
|
||||
assert "pushControllerReady.then(ensureDeviceSetup)" in dashboard
|
||||
assert "BASE + 'static/mobile-device-setup.js'" in worker
|
||||
assert "stackchain-dashboard-shell-v99" in worker
|
||||
assert "stackchain-dashboard-shell-v100" in worker
|
||||
assert ".device-setup-panel" in css
|
||||
assert ".device-readiness-card" in css
|
||||
assert "overflow-x:hidden" in css
|
||||
|
|
|
|||
|
|
@ -410,7 +410,7 @@ async def test_plan_today_wires_cancel_back_and_success_through_overlay_history(
|
|||
def test_plan_today_controller_is_available_in_the_offline_shell():
|
||||
source = SERVICE_WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v99" in source
|
||||
assert "stackchain-dashboard-shell-v100" in source
|
||||
assert "BASE + 'static/plan-today.js'" in source
|
||||
assert "BASE + 'static/plan-today-readiness.js'" in source
|
||||
assert "BASE + 'static/plan-today-preview.js'" in source
|
||||
|
|
|
|||
|
|
@ -150,7 +150,7 @@ async function dispatchPush(payload) {{
|
|||
def test_resumable_today_session_ships_in_a_new_offline_shell():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v99" in source
|
||||
assert "stackchain-dashboard-shell-v100" in source
|
||||
assert "BASE + 'static/my-work.js'" in source
|
||||
assert "BASE + 'static/dashboard.js'" in source
|
||||
assert "BASE + 'static/dashboard.css'" in source
|
||||
|
|
@ -159,14 +159,14 @@ def test_resumable_today_session_ships_in_a_new_offline_shell():
|
|||
def test_ownership_exit_runtime_rolls_the_offline_shell_cache():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v99" in source
|
||||
assert "stackchain-dashboard-shell-v100" in source
|
||||
assert "BASE + 'static/dashboard.js'" in source
|
||||
|
||||
|
||||
def test_offline_review_next_ships_today_completion_atomically():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v99" in source
|
||||
assert "stackchain-dashboard-shell-v100" in source
|
||||
assert "BASE + 'static/today-completion.js'" in source
|
||||
assert "BASE + 'static/dashboard.js'" in source
|
||||
|
||||
|
|
@ -174,7 +174,7 @@ def test_offline_review_next_ships_today_completion_atomically():
|
|||
def test_duplicate_aware_capture_ships_in_a_new_offline_shell():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v99" in source
|
||||
assert "stackchain-dashboard-shell-v100" in source
|
||||
assert "BASE + 'static/create-issue-sheet.js'" in source
|
||||
assert "BASE + 'static/dashboard.js'" in source
|
||||
|
||||
|
|
@ -182,14 +182,14 @@ def test_duplicate_aware_capture_ships_in_a_new_offline_shell():
|
|||
def test_exact_later_picker_ships_atomically_in_a_new_offline_shell():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v99" in source
|
||||
assert "stackchain-dashboard-shell-v100" in source
|
||||
assert "BASE + 'static/later-picker.js'" in source
|
||||
|
||||
|
||||
def test_navigation_deadline_ships_in_a_new_shell_cache():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v99" in source
|
||||
assert "stackchain-dashboard-shell-v100" in source
|
||||
assert "BASE + 'static/dashboard.css'" in source
|
||||
assert "BASE + 'static/dashboard.js'" in source
|
||||
assert "BASE + 'static/install-app.js'" in source
|
||||
|
|
@ -198,21 +198,21 @@ def test_navigation_deadline_ships_in_a_new_shell_cache():
|
|||
def test_today_convergence_ships_in_a_new_shell_cache():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v99" in source
|
||||
assert "stackchain-dashboard-shell-v100" in source
|
||||
assert "BASE + 'static/today-sync.js'" in source
|
||||
|
||||
|
||||
def test_mobile_search_viewport_ships_in_a_new_offline_shell():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v99" in source
|
||||
assert "stackchain-dashboard-shell-v100" in source
|
||||
assert "BASE + 'static/mobile-search-viewport.js'" in source
|
||||
|
||||
|
||||
def test_update_ownership_flow_ships_atomically_in_a_new_offline_shell():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v99" in source
|
||||
assert "stackchain-dashboard-shell-v100" in source
|
||||
assert "BASE + 'static/update-ownership.js'" in source
|
||||
|
||||
|
||||
|
|
@ -761,7 +761,7 @@ def test_one_session_bound_csrf_proof_is_reused_for_a_background_drain():
|
|||
def test_queue_today_ships_atomically_in_a_new_offline_shell():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v99" in source
|
||||
assert "stackchain-dashboard-shell-v100" in source
|
||||
assert "BASE + 'static/queue-today.js'" in source
|
||||
|
||||
|
||||
|
|
@ -846,6 +846,7 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
|
|||
"/dashboard/static/mobile-task-dock.js",
|
||||
"/dashboard/static/mobile-work-entry.js",
|
||||
"/dashboard/static/mobile-queue-launcher.js",
|
||||
"/dashboard/static/update-triage-session.js",
|
||||
"/dashboard/static/agenda-session-launcher.js",
|
||||
"/dashboard/static/mobile-launch.js",
|
||||
"/dashboard/static/mobile-app-shortcuts.js",
|
||||
|
|
|
|||
|
|
@ -221,7 +221,7 @@ async def test_today_blocker_opens_existing_preview_and_preserves_readiness_gate
|
|||
def test_readiness_runtime_is_available_in_offline_shell():
|
||||
service_worker = SERVICE_WORKER.read_text()
|
||||
|
||||
assert "const CACHE = 'stackchain-dashboard-shell-v99';" in service_worker
|
||||
assert "const CACHE = 'stackchain-dashboard-shell-v100';" in service_worker
|
||||
assert "BASE + 'static/today-readiness.js'" in service_worker
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -127,7 +127,7 @@ sync.enqueueConfiguration(120, {{'issue:r:1:':60}});
|
|||
def test_inflight_today_drain_ships_in_a_new_offline_shell():
|
||||
source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v99" in source
|
||||
assert "stackchain-dashboard-shell-v100" in source
|
||||
assert "BASE + 'static/today-sync.js'" in source
|
||||
|
||||
|
||||
|
|
|
|||
95
tests/test_update_triage_session.py
Normal file
95
tests/test_update_triage_session.py
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from tests.dashboard_bundle import dashboard
|
||||
|
||||
|
||||
SESSION = Path(__file__).resolve().parents[1] / "frontend" / "update-triage-session.js"
|
||||
|
||||
|
||||
def run_session(script):
|
||||
source = f"const createSession = require({json.dumps(str(SESSION))});\n" + script
|
||||
result = subprocess.run(["node", "-e", source], capture_output=True, text=True)
|
||||
assert result.returncode == 0, result.stderr
|
||||
return json.loads(result.stdout)
|
||||
|
||||
|
||||
def test_update_triage_persists_account_bound_pass_and_resumes_by_identity():
|
||||
result = run_session("""
|
||||
const values = new Map();
|
||||
let login = 'timmy';
|
||||
let items = [1,2,3].map(id => ({notification_id:id,title:'Update '+id}));
|
||||
const opened = [], progress = [];
|
||||
const options = {
|
||||
storage:{getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)},
|
||||
getLogin:()=>login, getItems:()=>items,
|
||||
onOpen:item=>opened.push(item.notification_id), onProgress:value=>progress.push(value), onFinish:()=>opened.push('done'),
|
||||
};
|
||||
let session = createSession(options);
|
||||
session.start();
|
||||
session.keepUnreadAndNext();
|
||||
const saved = JSON.parse(values.get('stackchain.update-triage.v1'));
|
||||
session = createSession(options);
|
||||
const resumable = session.resumable();
|
||||
session.resume();
|
||||
login = 'other';
|
||||
const isolated = session.resumable();
|
||||
process.stdout.write(JSON.stringify({opened, progress, saved, resumable, isolated}));
|
||||
""")
|
||||
|
||||
assert result["opened"] == [1, 2, 2]
|
||||
assert result["progress"] == [
|
||||
{"index": 1, "total": 3},
|
||||
{"index": 2, "total": 3},
|
||||
{"index": 2, "total": 3},
|
||||
]
|
||||
assert result["saved"] == {
|
||||
"version": 1,
|
||||
"login": "timmy",
|
||||
"identities": ["1", "2", "3"],
|
||||
"current": "2",
|
||||
"completed": ["1"],
|
||||
}
|
||||
assert result["resumable"] is True
|
||||
assert result["isolated"] is False
|
||||
|
||||
|
||||
def test_update_triage_reconciles_removed_items_and_does_not_admit_new_arrivals():
|
||||
result = run_session("""
|
||||
const values = new Map();
|
||||
let items = [1,2,3].map(notification_id => ({notification_id}));
|
||||
const opened = [], progress = [];
|
||||
const session = createSession({
|
||||
storage:{getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)},
|
||||
getLogin:()=> 'timmy', getItems:()=>items,
|
||||
onOpen:item=>opened.push(item.notification_id), onProgress:value=>progress.push(value), onFinish:()=>opened.push('done'),
|
||||
});
|
||||
session.start();
|
||||
items = [1,3,4].map(notification_id => ({notification_id}));
|
||||
session.completeAndNext();
|
||||
session.completeAndNext();
|
||||
process.stdout.write(JSON.stringify({opened, progress, stored:values.get('stackchain.update-triage.v1') || null}));
|
||||
""")
|
||||
|
||||
assert result == {
|
||||
"opened": [1, 3, "done"],
|
||||
"progress": [{"index": 1, "total": 3}, {"index": 3, "total": 3}],
|
||||
"stored": None,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_dashboard_wires_resumable_updates_triage_mobile_flow():
|
||||
html = await dashboard()
|
||||
|
||||
assert '<script src="static/update-triage-session.js"></script>' in html
|
||||
assert 'id="update-triage-progress"' in html
|
||||
assert 'id="keep-update-unread" type="button">Keep unread & next</button>' in html
|
||||
assert "openUpdates: openUpdateTriage" in html
|
||||
assert "updateTriage.acceptCompleted()" in html
|
||||
assert "updateTriage.keepUnreadAndNext()" in html
|
||||
assert "updateTriage.reconcile()" in html
|
||||
assert ".update-triage-progress" in html
|
||||
Loading…
Reference in New Issue
Block a user