191 lines
7.5 KiB
Python
191 lines
7.5 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"
|
|
|
|
|
|
def run_node(script):
|
|
return subprocess.run(
|
|
["node", "-e", script], check=True, capture_output=True, text=True
|
|
).stdout
|
|
|
|
|
|
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_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 "const filter = todayMyWork.length ? 'today' : (counts.attention ? 'attention' : 'all');" 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_retained_authenticated_context_keeps_local_planning_separate_from_fresh_delivery():
|
|
html = await dashboard()
|
|
|
|
assert "let planningOwnerLogin = '';" in html
|
|
assert html.count("getLogin: () => planningOwnerLogin") == 3
|
|
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
|