322 lines
12 KiB
Python
322 lines
12 KiB
Python
import json
|
|
import subprocess
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from tests.dashboard_bundle import dashboard
|
|
|
|
|
|
TODAY_WORK = Path(__file__).parents[1] / "frontend" / "today-work.js"
|
|
TODAY_COMPLETION = Path(__file__).parents[1] / "frontend" / "today-completion.js"
|
|
SERVICE_WORKER = Path(__file__).parents[1] / "frontend" / "service-worker.js"
|
|
|
|
|
|
def run_node(script):
|
|
return subprocess.run(
|
|
["node", "-e", script], check=True, capture_output=True, text=True
|
|
).stdout
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_dashboard_surfaces_automatic_planning_retry_guidance():
|
|
html = await dashboard()
|
|
|
|
assert "error.retryAfter = retryAfter === null ? undefined : Number(retryAfter)" in html
|
|
assert "Today saved on this device · retrying" in html
|
|
assert "Later saved on this device · retrying" in html
|
|
|
|
|
|
def test_today_queue_is_account_scoped_ordered_unique_and_bounded():
|
|
script = f"""
|
|
const createTodayWork = require({json.dumps(str(TODAY_WORK))});
|
|
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';
|
|
const queue = createTodayWork({{storage, getLogin: () => login, limit: 3}});
|
|
const issue = number => ({{kind:'issue', repository:'stackchain/dashboard', number, title:'Issue ' + number}});
|
|
const first = queue.add(issue(1));
|
|
const duplicate = queue.add(issue(1));
|
|
queue.add(issue(2));
|
|
queue.add(issue(3));
|
|
const full = queue.add(issue(4));
|
|
queue.move(issue(3), 'up');
|
|
queue.move(issue(3), 'up');
|
|
const timmy = queue.reconcile([issue(1), issue(2), issue(3), issue(4)]).map(item => item.number);
|
|
const persisted = createTodayWork({{storage, getLogin: () => login, limit: 3}})
|
|
.reconcile([issue(1), issue(2), issue(3)]).map(item => item.number);
|
|
login = 'alexander';
|
|
const isolated = queue.reconcile([issue(1), issue(2), issue(3)]).map(item => item.number);
|
|
process.stdout.write(JSON.stringify({{first, duplicate, full, timmy, persisted, isolated}}));
|
|
"""
|
|
|
|
assert json.loads(run_node(script)) == {
|
|
"first": "added",
|
|
"duplicate": "exists",
|
|
"full": "full",
|
|
"timmy": [3, 1, 2],
|
|
"persisted": [3, 1, 2],
|
|
"isolated": [],
|
|
}
|
|
|
|
|
|
def test_today_queue_exposes_reorder_boundaries_for_touch_controls():
|
|
script = f"""
|
|
const createTodayWork = require({json.dumps(str(TODAY_WORK))});
|
|
const values = new Map();
|
|
const storage = {{
|
|
getItem: key => values.get(key) || null,
|
|
setItem: (key, value) => values.set(key, value),
|
|
removeItem: key => values.delete(key),
|
|
}};
|
|
const queue = createTodayWork({{storage, getLogin: () => 'timmy'}});
|
|
const item = number => ({{kind:'issue', repository:'stackchain/dashboard', number}});
|
|
queue.add(item(1)); queue.add(item(2)); queue.add(item(3));
|
|
process.stdout.write(JSON.stringify([
|
|
queue.position(item(1)), queue.position(item(2)), queue.position(item(3)), queue.position(item(9))
|
|
]));
|
|
"""
|
|
|
|
assert json.loads(run_node(script)) == [
|
|
{"can_up": False, "can_down": True},
|
|
{"can_up": True, "can_down": True},
|
|
{"can_up": True, "can_down": False},
|
|
{"can_up": False, "can_down": False},
|
|
]
|
|
|
|
|
|
def test_done_for_today_removes_current_plan_item_syncs_and_advances_without_gitea_mutation():
|
|
script = f"""
|
|
const createTodayCompletion = require({json.dumps(str(TODAY_COMPLETION))});
|
|
const calls = [];
|
|
const current = {{kind:'issue', repository:'stackchain/dashboard', number:421}};
|
|
const complete = createTodayCompletion({{
|
|
todayWork: {{
|
|
identity: item => `issue:${{item.repository}}:${{item.number}}:`,
|
|
remove: item => {{ calls.push(`remove:${{item.number}}`); return true; }},
|
|
}},
|
|
todaySync: {{
|
|
enqueue: (action, identity) => {{ calls.push(`sync:${{action}}:${{identity}}`); return true; }},
|
|
flush: () => calls.push('flush'),
|
|
}},
|
|
refresh: () => calls.push('refresh'),
|
|
warm: () => calls.push('warm'),
|
|
workSession: {{ complete: () => calls.push('advance') }},
|
|
announce: message => calls.push(`announce:${{message}}`),
|
|
}});
|
|
const completed = complete(current);
|
|
process.stdout.write(JSON.stringify({{completed, calls}}));
|
|
"""
|
|
|
|
assert json.loads(run_node(script)) == {
|
|
"completed": True,
|
|
"calls": [
|
|
"remove:421",
|
|
"sync:remove:issue:stackchain/dashboard:421:",
|
|
"flush",
|
|
"refresh",
|
|
"warm",
|
|
"advance",
|
|
"announce:Done for Today. The Gitea item is unchanged.",
|
|
],
|
|
}
|
|
|
|
|
|
def test_done_for_today_does_not_advance_when_local_plan_persistence_fails():
|
|
script = f"""
|
|
const createTodayCompletion = require({json.dumps(str(TODAY_COMPLETION))});
|
|
const calls = [];
|
|
const complete = createTodayCompletion({{
|
|
todayWork: {{identity: () => 'issue:r:1:', remove: () => false}},
|
|
todaySync: {{enqueue: () => calls.push('sync'), flush: () => calls.push('flush')}},
|
|
refresh: () => calls.push('refresh'),
|
|
warm: () => calls.push('warm'),
|
|
workSession: {{complete: () => calls.push('advance')}},
|
|
announce: message => calls.push(message),
|
|
}});
|
|
const completed = complete({{kind:'issue', repository:'r', number:1}});
|
|
process.stdout.write(JSON.stringify({{completed, calls}}));
|
|
"""
|
|
|
|
assert json.loads(run_node(script)) == {
|
|
"completed": False,
|
|
"calls": ["Could not update Today on this device. Try again."],
|
|
}
|
|
|
|
|
|
def test_today_completion_reports_context_specific_success_and_partial_failure():
|
|
script = f"""
|
|
const createTodayCompletion = require({json.dumps(str(TODAY_COMPLETION))});
|
|
const messages = [];
|
|
let removable = false;
|
|
const complete = createTodayCompletion({{
|
|
todayWork: {{identity: () => 'review:r:7:', remove: () => removable}},
|
|
todaySync: {{enqueue: () => true, flush: () => undefined}},
|
|
refresh: () => undefined,
|
|
warm: () => undefined,
|
|
workSession: {{complete: () => undefined}},
|
|
announce: message => messages.push(message),
|
|
}});
|
|
const options = {{
|
|
successMessage: 'Review queued. Next Today item opened.',
|
|
failureMessage: 'Review queued, but Today still needs completion.',
|
|
}};
|
|
const failed = complete({{kind:'review', repository:'r', number:7}}, options);
|
|
removable = true;
|
|
const completed = complete({{kind:'review', repository:'r', number:7}}, options);
|
|
process.stdout.write(JSON.stringify({{failed, completed, messages}}));
|
|
"""
|
|
|
|
assert json.loads(run_node(script)) == {
|
|
"failed": False,
|
|
"completed": True,
|
|
"messages": [
|
|
"Review queued, but Today still needs completion.",
|
|
"Review queued. Next Today item opened.",
|
|
],
|
|
}
|
|
|
|
|
|
def test_done_for_today_logic_is_available_in_the_offline_app_shell():
|
|
assert "BASE + 'static/today-completion.js'" in SERVICE_WORKER.read_text()
|
|
|
|
|
|
def test_authoritative_reconciliation_reports_retired_ids_before_local_prune():
|
|
script = f"""
|
|
const createTodayWork = require({json.dumps(str(TODAY_WORK))});
|
|
const values = new Map();
|
|
const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}};
|
|
const queue = createTodayWork({{storage, getLogin: () => 'timmy'}});
|
|
const item = number => ({{kind:'issue', repository:'r', number}});
|
|
queue.add(item(1));
|
|
queue.add(item(2));
|
|
const reports = [];
|
|
const visible = queue.reconcile([item(2)], {{
|
|
pruneMissing: true,
|
|
onPrune: retiredIds => reports.push({{retiredIds, idsDuringReport: queue.read()}}),
|
|
}});
|
|
queue.reconcile([item(2)], {{
|
|
pruneMissing: true,
|
|
onPrune: retiredIds => reports.push({{retiredIds, idsDuringReport: queue.read()}}),
|
|
}});
|
|
process.stdout.write(JSON.stringify({{reports, visible:visible.map(entry => entry.number), stored:queue.read()}}));
|
|
"""
|
|
|
|
assert json.loads(run_node(script)) == {
|
|
"reports": [{
|
|
"retiredIds": ["issue:r:1:"],
|
|
"idsDuringReport": ["issue:r:1:", "issue:r:2:"],
|
|
}],
|
|
"visible": [2],
|
|
"stored": ["issue:r:2:"],
|
|
}
|
|
|
|
|
|
def test_failed_retirement_queue_keeps_local_identity_for_retry():
|
|
script = f"""
|
|
const createTodayWork = require({json.dumps(str(TODAY_WORK))});
|
|
const values = new Map();
|
|
const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}};
|
|
const queue = createTodayWork({{storage, getLogin: () => 'timmy'}});
|
|
const item = number => ({{kind:'issue', repository:'r', number}});
|
|
queue.add(item(1));
|
|
const visible = queue.reconcile([], {{pruneMissing:true, onPrune:() => false}});
|
|
process.stdout.write(JSON.stringify({{visible, stored:queue.read()}}));
|
|
"""
|
|
|
|
assert json.loads(run_node(script)) == {
|
|
"visible": [],
|
|
"stored": ["issue:r:1:"],
|
|
}
|
|
|
|
|
|
def test_today_queue_adopts_bounded_server_order():
|
|
script = f"""
|
|
const createTodayWork = require({json.dumps(str(TODAY_WORK))});
|
|
const values = new Map();
|
|
const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}};
|
|
const queue = createTodayWork({{storage, getLogin: () => 'timmy', limit: 2}});
|
|
queue.add({{kind:'issue', repository:'r', number:1}});
|
|
const adopted = queue.replace(['issue:r:3:', 'issue:r:2:', 'issue:r:2:', '', 'issue:r:1:']);
|
|
process.stdout.write(JSON.stringify({{adopted, ids:queue.read()}}));
|
|
"""
|
|
assert json.loads(run_node(script)) == {
|
|
"adopted": True,
|
|
"ids": ["issue:r:3:", "issue:r:2:"],
|
|
}
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_dashboard_runs_the_curated_today_queue_as_a_mobile_work_flow():
|
|
html = await dashboard()
|
|
|
|
assert '<script src="static/today-work.js"></script>' in html
|
|
assert '<script src="static/today-sync.js"></script>' in html
|
|
assert 'data-work-filter="today"' in html
|
|
assert 'data-work-count="today"' in html
|
|
assert "const todayWork = createTodayWork({" in html
|
|
assert "getTodayCount: () => todayMyWork.length" in html
|
|
assert "startToday: startTodaySession" in html
|
|
assert "selectedWorkFilter === 'today' ? todayMyWork : activeMyWork" in html
|
|
assert 'data-today-add' in html
|
|
assert 'data-today-remove' in html
|
|
assert 'data-today-move="up"' in html
|
|
assert 'data-today-move="down"' in html
|
|
assert 'Today is limited to 5 items' in html
|
|
assert '.today-actions button' in html and 'min-height:44px' in html
|
|
assert 'id="today-sync-status"' in html
|
|
assert "todaySync.enqueue('add'" in html
|
|
assert "todaySync.enqueue('remove'" in html
|
|
assert "todaySync.enqueue('move'" in html
|
|
assert "const authoritativeTodayReconciliation = liveMode && hasContextSnapshot &&" in html
|
|
assert "!lastContextSnapshot?.error &&" in html
|
|
assert "onPrune: retiredIds =>" in html
|
|
assert "retiredIds.map(id => todaySync.enqueue('remove', id)).every(Boolean)" in html
|
|
assert "if (queued) todaySync.flush();" in html
|
|
assert "todaySync.flush();" in html
|
|
assert "Another device filled Today · showing its saved plan." in html
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_today_session_sheets_offer_a_touch_safe_plan_only_completion_action():
|
|
html = await dashboard()
|
|
|
|
assert '<script src="static/today-completion.js"></script>' in html
|
|
assert html.count('data-work-session-complete=') == 4
|
|
assert html.count('Done for Today & next') == 4
|
|
assert "button.hidden = !workSession.checkpointed();" in html
|
|
assert "const completeTodayItem = createTodayCompletion({" in html
|
|
assert "refresh: () => refreshMyWorkView({ reconcileSession:false })," in html
|
|
assert "document.querySelectorAll('[data-work-session-complete]')" in html
|
|
assert "completeTodayItem(selectedSessionItem(button.dataset.workSessionComplete))" in html
|
|
assert ".work-session-nav button { min-height:44px; width:100%; }" in html
|
|
assert "grid-template-columns:repeat(3,minmax(0,1fr))" in html
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_dashboard_activates_today_lifecycle_convergence():
|
|
html = await dashboard()
|
|
|
|
assert "todaySync.startLifecycle({ window, document });" in html
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_retained_authenticated_context_keeps_local_planning_separate_from_fresh_delivery():
|
|
html = await dashboard()
|
|
|
|
assert "let planningOwnerLogin = '';" in html
|
|
assert html.count("getLogin: () => planningOwnerLogin") == 4
|
|
assert "const retainedPlanningLogin = !snapshot.context.error ?" in html
|
|
assert "planningOwnerLogin = retainedPlanningLogin;" in html
|
|
assert "button.disabled = !planningOwnerLogin;" in html
|
|
assert "activeFlushLogin = contextIdentityFresh ?" in html
|
|
assert "getOwnerLogin: () => confirmedOwnerLogin" in html
|
|
assert "Planning is unavailable until your operator identity is restored." in html
|
|
assert "Could not save Today on this device." in html
|
|
assert "Could not save Later on this device." in html
|
|
assert "data-planning-disabled" in html
|