feat: combine review attention app badge (Closes #1324)
This commit is contained in:
1 parent
157703527a
commit
33c8761e80
18 files changed
+287
-89
No files matched your search
@@ -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-v136" in worker
|
||||
assert "stackchain-dashboard-shell-v137" in worker
|
||||
@@ -489,7 +489,7 @@ def test_following_review_controls_are_wired_into_the_phone_preview_flow():
|
||||
assert "'Back to Following'" in dashboard
|
||||
assert "if (searchPreviewReturnKind === 'following')" in dashboard
|
||||
assert "e.key === 'Escape' && searchPreviewReturnKind === 'following'" in dashboard
|
||||
assert "stackchain-dashboard-shell-v136" in service_worker
|
||||
assert "stackchain-dashboard-shell-v137" in service_worker
|
||||
|
||||
|
||||
def test_prepare_today_lazily_refreshes_and_directly_reviews_following():
|
||||
@@ -504,8 +504,8 @@ def test_prepare_today_lazily_refreshes_and_directly_reviews_following():
|
||||
assert "const completed = feature.finishReview()" in following
|
||||
assert "if (completed) return 'completed-following'" in following
|
||||
assert "followingQueue.load().catch(() => {})" in dashboard
|
||||
assert "mobileQueueCounts.following = count" in dashboard
|
||||
assert "mobileQueueCounts.followingUnavailable = status === 'error'" in dashboard
|
||||
assert "mobilePreparationItems.following = items.filter" in dashboard
|
||||
assert "mobileQueueCounts.followingUnavailable ? followingQueue.open()" in dashboard
|
||||
assert "queueCounts.following = count" in dashboard
|
||||
assert "queueCounts.followingUnavailable = status === 'error'" in dashboard
|
||||
assert "preparationItems.following = items.filter" in dashboard
|
||||
assert "queueCounts.followingUnavailable ? followingQueue.open()" in dashboard
|
||||
assert "mobileStartDay.completePhase('following')" in dashboard
|
||||
@@ -435,5 +435,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-v136" in source
|
||||
assert "stackchain-dashboard-shell-v137" in source
|
||||
assert "BASE + 'static/later-sync.js'" in source
|
||||
@@ -256,4 +256,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-v136" in worker
|
||||
assert "stackchain-dashboard-shell-v137" in worker
|
||||
@@ -45,12 +45,12 @@ const controller = createMobileAppBadge({
|
||||
},
|
||||
});
|
||||
controller.start();
|
||||
await controller.reconcile(7, {authoritative:true});
|
||||
await controller.reconcile(12, {authoritative:false});
|
||||
await controller.reconcile('updates', 7, true);
|
||||
await controller.reconcile('updates', 12, false);
|
||||
control.checked = true;
|
||||
await listeners.change();
|
||||
await controller.reconcile(7, {authoritative:true});
|
||||
await controller.reconcile(0, {authoritative:true});
|
||||
await controller.reconcile('updates', 7, true);
|
||||
await controller.reconcile('updates', 0, true);
|
||||
console.log(JSON.stringify({values, clears, saved:storage.get('stackchain.app-badge.enabled.v1'), status:status.textContent}));
|
||||
""")
|
||||
assert result == {
|
||||
@@ -61,6 +61,33 @@ console.log(JSON.stringify({values, clears, saved:storage.get('stackchain.app-ba
|
||||
}
|
||||
|
||||
|
||||
def test_app_badge_combines_authoritative_update_and_following_channels_independently():
|
||||
result = run_badge("""
|
||||
const values = [];
|
||||
let clears = 0;
|
||||
const controller = createMobileAppBadge({
|
||||
control:{checked:true, disabled:false, addEventListener() {}},
|
||||
status:{textContent:''}, container:{hidden:false},
|
||||
navigator:{
|
||||
async setAppBadge(value) { values.push(value); },
|
||||
async clearAppBadge() { clears++; },
|
||||
},
|
||||
storage:{getItem() { return 'true'; }, setItem() {}, removeItem() {}},
|
||||
});
|
||||
controller.start();
|
||||
await controller.reconcile('updates', 3, true);
|
||||
await controller.reconcile('following', 2, true);
|
||||
await controller.reconcile('following', 0, true);
|
||||
await controller.reconcile('following', 4, false);
|
||||
await controller.reconcile('updates', 0, true);
|
||||
await controller.reconcile('following', 2, true);
|
||||
await controller.reconcile('updates', 5, true);
|
||||
await controller.reconcile('following', 2, true);
|
||||
console.log(JSON.stringify({values, clears}));
|
||||
""")
|
||||
assert result == {"values": [3, 5, 3, 2, 7], "clears": 1}
|
||||
|
||||
|
||||
def test_app_badge_hides_unsupported_device_control_without_touching_storage():
|
||||
result = run_badge("""
|
||||
const control = {checked:false, disabled:false, addEventListener() { throw new Error('must not wire'); }};
|
||||
@@ -92,9 +119,11 @@ def test_app_badge_is_packaged_touch_safe_and_reconciled_from_server_total():
|
||||
assert 'id="app-badge-control"' in index
|
||||
assert 'id="app-badge-status" role="status"' in index
|
||||
assert ".app-badge-control { min-height:44px;" in css
|
||||
assert "const mobileAppBadge = createMobileAppBadge({" in dashboard
|
||||
assert "mobileAppBadge.start();" in dashboard
|
||||
assert "mobileAppBadge.reconcile(pagination.total, {authoritative:true});" in dashboard
|
||||
assert "const appBadge = createMobileAppBadge({" in dashboard
|
||||
assert "appBadge.start();" in dashboard
|
||||
assert "appBadge.reconcile('updates', pagination.total, true);" in dashboard
|
||||
assert "appBadge.reconcile('following', count, true);" in dashboard
|
||||
assert "Show review attention on the app icon" in index
|
||||
|
||||
|
||||
def test_app_badge_exposes_device_setup_readiness_and_enable_action():
|
||||
@@ -113,7 +142,7 @@ const controller = createMobileAppBadge({
|
||||
},
|
||||
});
|
||||
controller.start();
|
||||
await controller.reconcile(4, {authoritative:true});
|
||||
await controller.reconcile('updates', 4, true);
|
||||
const before = controller.readiness();
|
||||
await controller.enable();
|
||||
const after = controller.readiness();
|
||||
@@ -122,11 +151,11 @@ console.log(JSON.stringify({before, after, checked:control.checked, values}));
|
||||
assert result == {
|
||||
"before": {
|
||||
"state": "incomplete",
|
||||
"detail": "Show the confirmed unread Updates count without opening Stackchain.",
|
||||
"detail": "Show review attention without opening Stackchain.",
|
||||
},
|
||||
"after": {
|
||||
"state": "complete",
|
||||
"detail": "The app icon shows the confirmed unread Updates count.",
|
||||
"detail": "The app icon shows review attention.",
|
||||
},
|
||||
"checked": True,
|
||||
"values": [4],
|
||||
@@ -142,8 +171,8 @@ def test_device_setup_offers_app_badge_as_an_optional_supported_step():
|
||||
assert 'id="device-setup-app-badge" type="button"' in index
|
||||
assert "['appBadge', options.appBadgeButton, options.appBadgeStatus, options.enableAppBadge, 'Enable']" in setup
|
||||
assert "appBadge:options.appBadgeReadiness()," in setup
|
||||
assert "appBadgeReadiness:() => mobileAppBadge.readiness()," in dashboard
|
||||
assert "enableAppBadge:() => mobileAppBadge.enable()," in dashboard
|
||||
assert "appBadgeReadiness:() => appBadge.readiness()," in dashboard
|
||||
assert "enableAppBadge:() => appBadge.enable()," in dashboard
|
||||
|
||||
|
||||
def test_app_badge_api_failure_is_reported_without_rejecting_live_refresh():
|
||||
@@ -156,7 +185,7 @@ const controller = createMobileAppBadge({
|
||||
storage:{getItem() { return 'true'; }, setItem() {}, removeItem() {}},
|
||||
});
|
||||
controller.start();
|
||||
const reconciled = await controller.reconcile(3, {authoritative:true});
|
||||
const reconciled = await controller.reconcile('updates', 3, true);
|
||||
console.log(JSON.stringify({reconciled, status:status.textContent}));
|
||||
""")
|
||||
assert result == {
|
||||
@@ -210,3 +239,51 @@ console.log(JSON.stringify(messages));
|
||||
{"type": "stackchain-app-badge-preference", "enabled": True},
|
||||
{"type": "stackchain-app-badge-preference", "enabled": False},
|
||||
]
|
||||
|
||||
|
||||
def test_app_badge_synchronizes_authoritative_channel_counts_with_worker():
|
||||
result = run_badge("""
|
||||
const messages = [];
|
||||
const controller = createMobileAppBadge({
|
||||
control:{checked:true, disabled:false, addEventListener() {}}, status:{textContent:''}, container:{hidden:false},
|
||||
navigator:{async setAppBadge() {}, async clearAppBadge() {}},
|
||||
storage:{getItem() { return 'true'; }, setItem() {}, removeItem() {}},
|
||||
serviceWorker:{ready:Promise.resolve({active:{postMessage(message) { messages.push(message); }}})},
|
||||
});
|
||||
controller.start();
|
||||
await controller.reconcile('updates', 3, true);
|
||||
await controller.reconcile('following', 2, true);
|
||||
await controller.reconcile('following', 9, false);
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
console.log(JSON.stringify(messages));
|
||||
""")
|
||||
assert result == [
|
||||
{"type": "stackchain-app-badge-preference", "enabled": True},
|
||||
{"type": "stackchain-app-badge-count", "channel": "updates", "count": 3},
|
||||
{"type": "stackchain-app-badge-count", "channel": "following", "count": 2},
|
||||
]
|
||||
|
||||
|
||||
def test_enabling_badge_synchronizes_counts_confirmed_while_opted_out():
|
||||
result = run_badge("""
|
||||
const listeners = {};
|
||||
const messages = [];
|
||||
const control = {checked:false, disabled:false, addEventListener(name, callback) { listeners[name] = callback; }};
|
||||
const controller = createMobileAppBadge({
|
||||
control, status:{textContent:''}, container:{hidden:false},
|
||||
navigator:{async setAppBadge() {}, async clearAppBadge() {}},
|
||||
storage:{getItem() { return null; }, setItem() {}, removeItem() {}},
|
||||
serviceWorker:{ready:Promise.resolve({active:{postMessage(message) { messages.push(message); }}})},
|
||||
});
|
||||
controller.start();
|
||||
await controller.reconcile('updates', 3, true);
|
||||
await controller.reconcile('following', 2, true);
|
||||
control.checked = true;
|
||||
await listeners.change();
|
||||
console.log(JSON.stringify(messages));
|
||||
""")
|
||||
assert result == [
|
||||
{"type": "stackchain-app-badge-preference", "enabled": True},
|
||||
{"type": "stackchain-app-badge-count", "channel": "updates", "count": 3},
|
||||
{"type": "stackchain-app-badge-count", "channel": "following", "count": 2},
|
||||
]
|
||||
@@ -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-v136" in worker
|
||||
assert "stackchain-dashboard-shell-v137" in worker
|
||||
|
||||
|
||||
def test_all_conversation_composers_offer_accessible_mobile_mentions():
|
||||
|
||||
@@ -383,7 +383,7 @@ def test_mobile_dashboard_mounts_phone_safe_device_setup_flow():
|
||||
assert "controller.recoverPermission('deadline')" in dashboard
|
||||
assert "pushControllerReady.then(ensureDeviceSetup)" in dashboard
|
||||
assert "BASE + 'static/mobile-device-setup.js'" in worker
|
||||
assert "stackchain-dashboard-shell-v136" in worker
|
||||
assert "stackchain-dashboard-shell-v137" in worker
|
||||
assert ".device-setup-panel" in css
|
||||
assert ".device-readiness-card" in css
|
||||
assert "overflow-x:hidden" in css
|
||||
|
||||
@@ -243,5 +243,5 @@ async def test_mobile_home_progressively_discloses_secondary_panels_as_insights(
|
||||
def test_mobile_insights_rolls_into_the_offline_shell():
|
||||
worker = (CONTROLLER.parent / "service-worker.js").read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v136" in worker
|
||||
assert "stackchain-dashboard-shell-v137" in worker
|
||||
assert "BASE + 'static/mobile-insights.js'" in worker
|
||||
@@ -407,14 +407,14 @@ async def test_dashboard_wires_thumb_safe_start_day_briefing_into_offline_mobile
|
||||
assert "mobileStartDay.finish()" in html
|
||||
assert "openQueue: name =>" in html
|
||||
assert "if (sheet.open) sheet.close();" in html
|
||||
assert "mobileQueueCounts.followingUnavailable ? followingQueue.open()" in html
|
||||
assert "queueCounts.followingUnavailable ? followingQueue.open()" in html
|
||||
assert "Checking Agenda, Attention, Updates, Filed, and Following" in html
|
||||
assert "mobileStartDay.render();" in html
|
||||
assert ".mobile-start-day-action { width:100%; min-height:48px;" in html
|
||||
assert ".mobile-start-day-finish { min-height:44px;" in html
|
||||
assert "max-width:100%; overflow-wrap:anywhere;" in html
|
||||
assert "BASE + 'static/mobile-start-day.js'" in service_worker
|
||||
assert "stackchain-dashboard-shell-v136" in service_worker
|
||||
assert "stackchain-dashboard-shell-v137" in service_worker
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
@@ -427,9 +427,9 @@ async def test_primary_mobile_work_action_opens_a_unique_item_prepare_today_brie
|
||||
assert "qs('#mobile-queue-heading').textContent = state.active ? 'Resume Prepare Today' : 'Prepare Today';" in html
|
||||
assert "qs('#mobile-queue-sheet').showModal();" in html
|
||||
assert "qs('#mobile-start-day-action').focus();" in html
|
||||
assert "let mobilePreparationItems = {};" in html
|
||||
assert "getPhaseItems: () => mobilePreparationItems" in html
|
||||
assert "mobilePreparationItems = {" in html
|
||||
assert "let preparationItems = {};" in html
|
||||
assert "getPhaseItems: () => preparationItems" in html
|
||||
assert "preparationItems = {" in html
|
||||
assert "delivery:draftInbox.partition(lastDrafts).deliveries" in html
|
||||
assert "agenda:agendaMyWork(activeMyWork)" in html
|
||||
assert "attention:activeMyWork.filter(item => item.needs_attention)" in html
|
||||
|
||||
@@ -835,7 +835,7 @@ async def test_dashboard_renders_and_wires_mobile_queue_switcher():
|
||||
assert "const mobileQueueLauncher = createMobileQueueLauncher({" in html
|
||||
assert "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')" in html
|
||||
assert "name === 'find' ? qs('#find-work').click() :" in html
|
||||
assert "mobileQueueCounts.followingUnavailable ? followingQueue.open()" in html
|
||||
assert "queueCounts.followingUnavailable ? followingQueue.open()" in html
|
||||
assert '.mobile-queue-sheet' in html
|
||||
assert '.my-work-actions { display:none;' in html
|
||||
assert 'padding-bottom:calc(16px + env(safe-area-inset-bottom))' in html
|
||||
@@ -873,7 +873,7 @@ async def test_dashboard_wires_work_dock_and_app_shortcut_to_live_queue_recommen
|
||||
assert "queueLauncher: mobileQueueLauncher" in html
|
||||
assert "work: () => mobileWorkEntry.open()" in html
|
||||
assert "continueWork: () => mobileWorkEntry.open()" in html
|
||||
assert "mobileQueueCounts = counts;" in html
|
||||
assert "queueCounts = counts;" in html
|
||||
assert "mobileTaskDock.updateWork(mobileWorkEntry.mode())" in html
|
||||
|
||||
|
||||
@@ -895,7 +895,7 @@ async def test_dashboard_renders_and_wires_phone_safe_task_dock():
|
||||
assert "createMobileTaskDock({" in html
|
||||
assert "work: () => mobileWorkEntry.open()" in html
|
||||
assert "name === 'find' ? qs('#find-work').click() :" in html
|
||||
assert "mobileQueueCounts.followingUnavailable ? followingQueue.open()" in html
|
||||
assert "queueCounts.followingUnavailable ? followingQueue.open()" in html
|
||||
assert "qs('[data-work-filter=\"' + name + '\"]').click()" in html
|
||||
assert "find: () => qs('#find-work').click()" in html
|
||||
assert "new: () => qs('#new-issue').click()" in html
|
||||
|
||||
@@ -418,7 +418,7 @@ async def test_starting_saved_today_work_closes_a_concurrent_rollover_planner():
|
||||
def test_plan_today_controller_is_available_in_the_offline_shell():
|
||||
source = SERVICE_WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v136" in source
|
||||
assert "stackchain-dashboard-shell-v137" 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
|
||||
@@ -14,7 +14,7 @@ def run_worker_scenario(scenario: str) -> dict:
|
||||
const fs = require('fs');
|
||||
const vm = require('vm');
|
||||
const listeners = {{}};
|
||||
const state = {{ added: [], addAttempts: [], individuallyAdded: [], failedAdds: [], deleted: [], deletedDatabases: [], claimed: false, skipped: false, fetches: [], puts: [], migrated: [], activationOrder: [], oldCachedAssets: {{}}, sharedRecords: {{}}, todayCommands: {{}}, failTodayCommandPut: false, failSharedPut: false, backgroundFlushes: 0, backgroundResumes: 0, outboxPurges: 0, outboxLifecycle: [], notifications: [], closedNotifications: 0, focused: [], opened: [], appBadges: [], clearedAppBadges: 0, badgeEnabled: false, failFetch: false, stallFetch: false, lateFetch: false, fetchAborted: false, fetchStatus: 200, fetchRedirected: false, cachedBody: null }};
|
||||
const state = {{ added: [], addAttempts: [], individuallyAdded: [], failedAdds: [], deleted: [], deletedDatabases: [], claimed: false, skipped: false, fetches: [], puts: [], migrated: [], activationOrder: [], oldCachedAssets: {{}}, sharedRecords: {{}}, todayCommands: {{}}, failTodayCommandPut: false, failSharedPut: false, backgroundFlushes: 0, backgroundResumes: 0, outboxPurges: 0, outboxLifecycle: [], notifications: [], closedNotifications: 0, focused: [], opened: [], appBadges: [], clearedAppBadges: 0, badgeEnabled: false, badgeCounts: {{updates:0, following:0}}, failFetch: false, stallFetch: false, lateFetch: false, fetchAborted: false, fetchStatus: 200, fetchRedirected: false, cachedBody: null }};
|
||||
const storedResponses = new Map();
|
||||
storedResponses.set(
|
||||
'https://forge.example/dashboard/__offline-session-lease',
|
||||
@@ -81,6 +81,9 @@ const context = {{
|
||||
__STACKCHAIN_APP_BADGE_PREFERENCE: {{
|
||||
get: async () => state.badgeEnabled,
|
||||
set: async enabled => {{ state.badgeEnabled = enabled; }},
|
||||
getCounts: async () => ({{...state.badgeCounts}}),
|
||||
setCount: async (channel, count) => {{ state.badgeCounts[channel] = count; }},
|
||||
clearCounts: async () => {{ state.badgeCounts = {{updates:0, following:0}}; }},
|
||||
}},
|
||||
addEventListener: (name, handler) => {{ listeners[name] = handler; }},
|
||||
skipWaiting: async () => {{ state.skipped = true; }},
|
||||
@@ -186,7 +189,7 @@ async function dispatchPush(payload) {{
|
||||
def test_week_unplan_undo_rolls_the_offline_shell():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v136" in source
|
||||
assert "stackchain-dashboard-shell-v137" in source
|
||||
assert "BASE + 'static/week-plan.js'" in source
|
||||
assert "BASE + 'static/dashboard.css'" in source
|
||||
|
||||
@@ -194,20 +197,20 @@ def test_week_unplan_undo_rolls_the_offline_shell():
|
||||
def test_private_today_action_mailbox_rolls_the_offline_shell():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v136" in source
|
||||
assert "stackchain-dashboard-shell-v137" in source
|
||||
|
||||
|
||||
def test_per_day_week_conflict_ui_rolls_the_offline_shell():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v136" in source
|
||||
assert "stackchain-dashboard-shell-v137" in source
|
||||
assert "BASE + 'static/week-plan.js'" in source
|
||||
|
||||
|
||||
def test_resumable_today_session_ships_in_a_new_offline_shell():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v136" in source
|
||||
assert "stackchain-dashboard-shell-v137" in source
|
||||
assert "BASE + 'static/my-work.js'" in source
|
||||
assert "BASE + 'static/dashboard.js'" in source
|
||||
assert "BASE + 'static/dashboard.css'" in source
|
||||
@@ -216,7 +219,7 @@ def test_resumable_today_session_ships_in_a_new_offline_shell():
|
||||
def test_mobile_conversation_photo_bundles_roll_the_offline_shell():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v136" in source
|
||||
assert "stackchain-dashboard-shell-v137" in source
|
||||
assert "BASE + 'static/dashboard.js'" in source
|
||||
assert "BASE + 'static/authored-outbox.js'" in source
|
||||
assert "BASE + 'static/background-issue-sync.js'" in source
|
||||
@@ -225,7 +228,7 @@ def test_mobile_conversation_photo_bundles_roll_the_offline_shell():
|
||||
def test_photo_metadata_sanitizer_rolls_the_cached_optimizer_atomically():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v136" in source
|
||||
assert "stackchain-dashboard-shell-v137" in source
|
||||
assert "BASE + 'static/issue-evidence-review.js'" in source
|
||||
assert "BASE + 'static/issue-attachment.js'" in source
|
||||
|
||||
@@ -233,14 +236,14 @@ def test_photo_metadata_sanitizer_rolls_the_cached_optimizer_atomically():
|
||||
def test_ownership_exit_runtime_rolls_the_offline_shell_cache():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v136" in source
|
||||
assert "stackchain-dashboard-shell-v137" 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-v136" in source
|
||||
assert "stackchain-dashboard-shell-v137" in source
|
||||
assert "BASE + 'static/today-completion.js'" in source
|
||||
assert "BASE + 'static/dashboard.js'" in source
|
||||
|
||||
@@ -248,7 +251,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-v136" in source
|
||||
assert "stackchain-dashboard-shell-v137" in source
|
||||
assert "BASE + 'static/create-issue-sheet.js'" in source
|
||||
assert "BASE + 'static/dashboard.js'" in source
|
||||
|
||||
@@ -256,7 +259,7 @@ def test_duplicate_aware_capture_ships_in_a_new_offline_shell():
|
||||
def test_inline_checklist_step_flow_rolls_the_offline_shell_atomically():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v136" in source
|
||||
assert "stackchain-dashboard-shell-v137" in source
|
||||
assert "BASE + 'static/issue-sheet.js'" in source
|
||||
assert "BASE + 'static/checklist-conflict.js'" in source
|
||||
assert "BASE + 'static/dashboard.js'" in source
|
||||
@@ -266,14 +269,14 @@ def test_inline_checklist_step_flow_rolls_the_offline_shell_atomically():
|
||||
def test_exact_later_picker_ships_atomically_in_a_new_offline_shell():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v136" in source
|
||||
assert "stackchain-dashboard-shell-v137" 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-v136" in source
|
||||
assert "stackchain-dashboard-shell-v137" in source
|
||||
assert "BASE + 'static/dashboard.css'" in source
|
||||
assert "BASE + 'static/dashboard.js'" in source
|
||||
assert "BASE + 'static/install-app.js'" in source
|
||||
@@ -282,21 +285,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-v136" in source
|
||||
assert "stackchain-dashboard-shell-v137" 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-v136" in source
|
||||
assert "stackchain-dashboard-shell-v137" 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-v136" in source
|
||||
assert "stackchain-dashboard-shell-v137" in source
|
||||
assert "BASE + 'static/update-ownership.js'" in source
|
||||
|
||||
|
||||
@@ -999,6 +1002,55 @@ def test_opted_in_background_updates_reconcile_authoritative_badge_without_churn
|
||||
assert len(result["notifications"]) == 4
|
||||
|
||||
|
||||
def test_background_badge_combines_following_and_updates_without_channel_overwrite():
|
||||
result = run_worker_scenario(
|
||||
"""
|
||||
await dispatchMessage({type:'stackchain-app-badge-preference', enabled:true}, [], {url:'https://forge.example/dashboard/'});
|
||||
await dispatchPush({tag:'stackchain-following-0123456789abcdef', route:'#/my-work/following', following_count:2});
|
||||
await dispatchPush({tag:'stackchain-update-42', route:'#/my-work/update/42', notification_id:42, unread_count:3});
|
||||
await dispatchPush({tag:'stackchain-following-fedcba9876543210', route:'#/my-work/following', following_count:'invalid'});
|
||||
await dispatchPush({tag:'stackchain-following-1111111111111111', route:'#/my-work/following', following_count:1});
|
||||
await dispatchPush({tag:'stackchain-update-digest', route:'#/my-work/updates', update_count:1, unread_count:3});
|
||||
process.stdout.write(JSON.stringify(state));
|
||||
"""
|
||||
)
|
||||
|
||||
assert result["appBadges"] == [2, 5, 4]
|
||||
assert result["badgeCounts"] == {"updates": 3, "following": 1}
|
||||
assert result["clearedAppBadges"] == 0
|
||||
|
||||
|
||||
def test_worker_accepts_only_authenticated_authoritative_badge_channel_counts():
|
||||
result = run_worker_scenario(
|
||||
"""
|
||||
state.badgeEnabled = true;
|
||||
await dispatchMessage({type:'stackchain-app-badge-count', channel:'following', count:2}, [], {url:'https://forge.example/dashboard/'});
|
||||
await dispatchMessage({type:'stackchain-app-badge-count', channel:'updates', count:8}, [], {url:'https://evil.example/dashboard/'});
|
||||
await dispatchMessage({type:'stackchain-app-badge-count', channel:'following', count:-1}, [], {url:'https://forge.example/dashboard/'});
|
||||
await dispatchPush({tag:'stackchain-update-42', route:'#/my-work/update/42', notification_id:42, unread_count:3});
|
||||
process.stdout.write(JSON.stringify(state));
|
||||
"""
|
||||
)
|
||||
|
||||
assert result["badgeCounts"] == {"updates": 3, "following": 2}
|
||||
assert result["appBadges"] == [5]
|
||||
|
||||
|
||||
def test_foreground_channel_sync_invalidates_worker_render_cache_for_next_push():
|
||||
result = run_worker_scenario(
|
||||
"""
|
||||
state.badgeEnabled = true;
|
||||
await dispatchPush({tag:'stackchain-following-0123456789abcdef', route:'#/my-work/following', following_count:2});
|
||||
await dispatchMessage({type:'stackchain-app-badge-count', channel:'following', count:0}, [], {url:'https://forge.example/dashboard/'});
|
||||
await dispatchPush({tag:'stackchain-update-42', route:'#/my-work/update/42', notification_id:42, unread_count:2});
|
||||
process.stdout.write(JSON.stringify(state));
|
||||
"""
|
||||
)
|
||||
|
||||
assert result["badgeCounts"] == {"updates": 2, "following": 0}
|
||||
assert result["appBadges"] == [2, 2]
|
||||
|
||||
|
||||
def test_disabling_background_badge_clears_it_and_future_updates_leave_it_off():
|
||||
result = run_worker_scenario(
|
||||
"""
|
||||
@@ -1302,7 +1354,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-v136" in source
|
||||
assert "stackchain-dashboard-shell-v137" in source
|
||||
assert "BASE + 'static/queue-today.js'" in source
|
||||
|
||||
|
||||
|
||||
@@ -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-v136';" in service_worker
|
||||
assert "const CACHE = 'stackchain-dashboard-shell-v137';" in service_worker
|
||||
assert "BASE + 'static/today-readiness.js'" in service_worker
|
||||
|
||||
|
||||
|
||||
@@ -343,7 +343,7 @@ listeners['stackchain:first-task-complete']();
|
||||
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-v136" in source
|
||||
assert "stackchain-dashboard-shell-v137" in source
|
||||
assert "BASE + 'static/today-sync.js'" in source
|
||||
|
||||
|
||||
|
||||
Reference in new issue
Block a user