feat: plan Today by available time (#459)
This commit is contained in:
1 parent
e3c07c2dfb
commit
161977cb67
20 files changed
+570
-73
No files matched your search
@@ -281,4 +281,4 @@ async def test_current_today_update_offers_reply_and_next_without_marking_read()
|
||||
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-v78" in worker
|
||||
assert "stackchain-dashboard-shell-v79" in worker
|
||||
@@ -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-v78" in source
|
||||
assert "stackchain-dashboard-shell-v79" 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-v78" in worker
|
||||
assert "stackchain-dashboard-shell-v79" in worker
|
||||
@@ -35,7 +35,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-v78" in worker
|
||||
assert "stackchain-dashboard-shell-v79" in worker
|
||||
|
||||
|
||||
def test_all_conversation_composers_offer_accessible_mobile_mentions():
|
||||
|
||||
@@ -79,6 +79,54 @@ process.stdout.write(JSON.stringify({{result, calls, snapshot:planner.snapshot()
|
||||
}
|
||||
|
||||
|
||||
def test_plan_today_calculates_capacity_and_confirms_overcommitment_before_saving():
|
||||
script = f"""
|
||||
const createPlanToday = require({json.dumps(str(PLAN_TODAY))});
|
||||
const item = number => ({{kind:'issue', repository:'stackchain/dashboard', number, title:'Issue ' + number}});
|
||||
const saved = [];
|
||||
const planner = createPlanToday({{
|
||||
identity: value => 'issue:stackchain/dashboard:' + value.number + ':',
|
||||
save: plan => saved.push(plan),
|
||||
}});
|
||||
planner.open([item(1), item(2)], [item(1), item(2)]);
|
||||
planner.setCapacity(90);
|
||||
planner.setEstimate('issue:stackchain/dashboard:1:', 60);
|
||||
planner.setEstimate('issue:stackchain/dashboard:2:', 45);
|
||||
const over = planner.snapshot();
|
||||
const confirmation = planner.commit();
|
||||
const stillOpen = planner.snapshot().open;
|
||||
const result = planner.commit({{confirmOverCapacity:true}});
|
||||
process.stdout.write(JSON.stringify({{over, confirmation, stillOpen, result, saved}}));
|
||||
"""
|
||||
result = run_node(script)
|
||||
assert result["over"] == {
|
||||
"open": True,
|
||||
"ids": ["issue:stackchain/dashboard:1:", "issue:stackchain/dashboard:2:"],
|
||||
"count": 2,
|
||||
"limit": 5,
|
||||
"capacity_minutes": 90,
|
||||
"estimates": {
|
||||
"issue:stackchain/dashboard:1:": 60,
|
||||
"issue:stackchain/dashboard:2:": 45,
|
||||
},
|
||||
"planned_minutes": 105,
|
||||
"remaining_minutes": -15,
|
||||
"unestimated_count": 0,
|
||||
"over_capacity": True,
|
||||
}
|
||||
assert result["confirmation"] == "confirm-over-capacity"
|
||||
assert result["stillOpen"] is True
|
||||
assert result["result"] == "saved"
|
||||
assert result["saved"] == [{
|
||||
"ids": ["issue:stackchain/dashboard:1:", "issue:stackchain/dashboard:2:"],
|
||||
"capacity_minutes": 90,
|
||||
"estimates": {
|
||||
"issue:stackchain/dashboard:1:": 60,
|
||||
"issue:stackchain/dashboard:2:": 45,
|
||||
},
|
||||
}]
|
||||
|
||||
|
||||
def test_plan_today_preview_preserves_draft_scroll_and_adds_item_once_on_return():
|
||||
script = f"""
|
||||
const createPlanToday = require({json.dumps(str(PLAN_TODAY))});
|
||||
@@ -192,6 +240,17 @@ async def test_mobile_dashboard_wires_focused_plan_today_sheet():
|
||||
assert 'id="plan-today"' in html
|
||||
assert 'id="plan-today-sheet" role="dialog"' in html
|
||||
assert 'id="plan-today-capacity"' in html
|
||||
assert 'id="plan-today-available"' in html
|
||||
assert 'inputmode="numeric" min="15" max="1440"' in html
|
||||
assert 'data-plan-estimate="' in html
|
||||
assert 'aria-label="Estimate for ' in html
|
||||
assert "formatPlanMinutes(state.planned_minutes)" in html
|
||||
assert "planToday.setCapacity" in html
|
||||
assert "planToday.setEstimate" in html
|
||||
assert "todaySync.enqueueConfiguration" in html
|
||||
assert "todayWork.runway(todayMyWork, state.index - 1)" in html
|
||||
assert "resetPlanTodayConfirmation()" in html
|
||||
assert "result === 'confirm-over-capacity'" in html
|
||||
assert 'id="save-and-start-today"' in html
|
||||
assert "const planToday = createPlanToday({" in html
|
||||
assert "todaySync.enqueue('remove'" in html
|
||||
@@ -226,12 +285,13 @@ async def test_plan_today_wires_cancel_back_and_success_through_overlay_history(
|
||||
assert "kind === 'plan-today' && previous !== 'plan-today'" in html
|
||||
assert "openPlanToday(planTodayTrigger, false)" in html
|
||||
assert "closePlanToday(false)" in html
|
||||
assert "if (result === 'saved') taskOverlayHistory.leave();" in html
|
||||
assert "if (result === 'saved')" in html
|
||||
assert "taskOverlayHistory.leave();" in html
|
||||
|
||||
|
||||
def test_plan_today_controller_is_available_in_the_offline_shell():
|
||||
source = SERVICE_WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v78" in source
|
||||
assert "stackchain-dashboard-shell-v79" in source
|
||||
assert "BASE + 'static/plan-today.js'" in source
|
||||
assert "BASE + 'static/plan-today-preview.js'" in source
|
||||
@@ -122,7 +122,7 @@ async function dispatchNotificationClick(route) {{
|
||||
def test_resumable_today_session_ships_in_a_new_offline_shell():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v78" in source
|
||||
assert "stackchain-dashboard-shell-v79" in source
|
||||
assert "BASE + 'static/my-work.js'" in source
|
||||
assert "BASE + 'static/dashboard.js'" in source
|
||||
assert "BASE + 'static/dashboard.css'" in source
|
||||
@@ -131,14 +131,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-v78" in source
|
||||
assert "stackchain-dashboard-shell-v79" 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-v78" in source
|
||||
assert "stackchain-dashboard-shell-v79" in source
|
||||
assert "BASE + 'static/today-completion.js'" in source
|
||||
assert "BASE + 'static/dashboard.js'" in source
|
||||
|
||||
@@ -146,7 +146,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-v78" in source
|
||||
assert "stackchain-dashboard-shell-v79" in source
|
||||
assert "BASE + 'static/create-issue-sheet.js'" in source
|
||||
assert "BASE + 'static/dashboard.js'" in source
|
||||
|
||||
@@ -154,14 +154,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-v78" in source
|
||||
assert "stackchain-dashboard-shell-v79" 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-v78" in source
|
||||
assert "stackchain-dashboard-shell-v79" in source
|
||||
assert "BASE + 'static/dashboard.css'" in source
|
||||
assert "BASE + 'static/dashboard.js'" in source
|
||||
assert "BASE + 'static/install-app.js'" in source
|
||||
@@ -170,21 +170,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-v78" in source
|
||||
assert "stackchain-dashboard-shell-v79" 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-v78" in source
|
||||
assert "stackchain-dashboard-shell-v79" 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-v78" in source
|
||||
assert "stackchain-dashboard-shell-v79" in source
|
||||
assert "BASE + 'static/update-ownership.js'" in source
|
||||
|
||||
|
||||
@@ -365,7 +365,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-v78" in source
|
||||
assert "stackchain-dashboard-shell-v79" 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-v78';" in service_worker
|
||||
assert "const CACHE = 'stackchain-dashboard-shell-v79';" in service_worker
|
||||
assert "BASE + 'static/today-readiness.js'" in service_worker
|
||||
|
||||
|
||||
|
||||
@@ -36,6 +36,8 @@ def test_operations_are_durable_ordered_idempotent_and_account_scoped(tmp_path):
|
||||
assert store.apply("timmy", "op-1", "add", "issue:stackchain/dashboard:1:") == {
|
||||
"revision": 1,
|
||||
"ids": ["issue:stackchain/dashboard:1:"],
|
||||
"capacity_minutes": None,
|
||||
"estimates": {},
|
||||
}
|
||||
store.apply("timmy", "op-2", "add", "issue:stackchain/dashboard:2:")
|
||||
store.apply("timmy", "op-3", "add", "issue:stackchain/dashboard:3:")
|
||||
@@ -53,9 +55,49 @@ def test_operations_are_durable_ordered_idempotent_and_account_scoped(tmp_path):
|
||||
"issue:stackchain/dashboard:3:",
|
||||
"issue:stackchain/dashboard:2:",
|
||||
],
|
||||
"capacity_minutes": None,
|
||||
"estimates": {},
|
||||
}
|
||||
assert TodayStore(path, limit=3).get("timmy") == moved
|
||||
assert store.get("alexander") == {"revision": 0, "ids": []}
|
||||
assert store.get("alexander") == {
|
||||
"revision": 0,
|
||||
"ids": [],
|
||||
"capacity_minutes": None,
|
||||
"estimates": {},
|
||||
}
|
||||
|
||||
|
||||
def test_capacity_and_estimates_are_durable_account_scoped_and_follow_item_identity(tmp_path):
|
||||
path = tmp_path / "today.sqlite3"
|
||||
store = TodayStore(path, limit=3)
|
||||
store.apply("timmy", "add-1", "add", "issue:r:1:")
|
||||
store.apply("timmy", "add-2", "add", "issue:r:2:")
|
||||
|
||||
configured = store.apply_batch("timmy", [{
|
||||
"operation_id": "capacity-1",
|
||||
"action": "configure",
|
||||
"item_id": "plan",
|
||||
"capacity_minutes": 180,
|
||||
"estimates": {"issue:r:1:": 60, "issue:r:2:": 45, "issue:r:99:": 30},
|
||||
"base_revision": 2,
|
||||
}])
|
||||
store.apply("timmy", "move-2", "move", "issue:r:2:", direction="up")
|
||||
store.apply("timmy", "remove-1", "remove", "issue:r:1:")
|
||||
|
||||
assert configured["capacity_minutes"] == 180
|
||||
assert configured["estimates"] == {"issue:r:1:": 60, "issue:r:2:": 45}
|
||||
assert TodayStore(path, limit=3).get("timmy") == {
|
||||
"revision": 5,
|
||||
"ids": ["issue:r:2:"],
|
||||
"capacity_minutes": 180,
|
||||
"estimates": {"issue:r:2:": 45},
|
||||
}
|
||||
assert store.get("alexander") == {
|
||||
"revision": 0,
|
||||
"ids": [],
|
||||
"capacity_minutes": None,
|
||||
"estimates": {},
|
||||
}
|
||||
|
||||
|
||||
def test_limit_is_atomic_and_remove_frees_capacity(tmp_path):
|
||||
@@ -69,6 +111,8 @@ def test_limit_is_atomic_and_remove_frees_capacity(tmp_path):
|
||||
assert store.get("timmy") == {
|
||||
"revision": 2,
|
||||
"ids": ["issue:r:1:", "issue:r:2:"],
|
||||
"capacity_minutes": None,
|
||||
"estimates": {},
|
||||
}
|
||||
store.apply("timmy", "remove", "remove", "issue:r:1:")
|
||||
assert store.apply("timmy", "retry-three", "add", "issue:r:3:")["ids"] == [
|
||||
@@ -94,6 +138,8 @@ def test_batch_applies_ordered_operations_once_and_rejects_only_capacity_conflic
|
||||
assert result == {
|
||||
"revision": 4,
|
||||
"ids": ["issue:r:2:", "issue:r:3:"],
|
||||
"capacity_minutes": None,
|
||||
"estimates": {},
|
||||
"accepted_operation_ids": ["one", "two", "remove", "three"],
|
||||
"duplicate_operation_ids": [],
|
||||
"rejected_operations": [{"operation_id": "full", "reason": "today_full"}],
|
||||
@@ -197,6 +243,48 @@ def test_today_api_model_preserves_client_base_revision():
|
||||
assert operation.model_dump()["base_revision"] == 7
|
||||
|
||||
|
||||
def test_today_api_model_accepts_bounded_capacity_configuration():
|
||||
operation = main.TodayOperation(
|
||||
operation_id="capacity", action="configure", item_id="plan",
|
||||
capacity_minutes=180,
|
||||
estimates={"issue:r:1:": 60, "issue:r:2:": 45},
|
||||
base_revision=2,
|
||||
)
|
||||
|
||||
assert operation.model_dump() == {
|
||||
"operation_id": "capacity",
|
||||
"action": "configure",
|
||||
"item_id": "plan",
|
||||
"direction": None,
|
||||
"base_revision": 2,
|
||||
"capacity_minutes": 180,
|
||||
"estimates": {"issue:r:1:": 60, "issue:r:2:": 45},
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_single_capacity_configuration_uses_atomic_batch_path(monkeypatch):
|
||||
async def user():
|
||||
return {"login": "timmy"}
|
||||
|
||||
calls = []
|
||||
class Store:
|
||||
def apply_batch(self, login, operations):
|
||||
calls.append((login, operations))
|
||||
return {"revision": 1, "ids": [], "capacity_minutes": 180, "estimates": {}}
|
||||
|
||||
monkeypatch.setattr(main, "current_user", user)
|
||||
monkeypatch.setattr(main, "_today_store", lambda: Store())
|
||||
payload = main.TodayOperation(
|
||||
operation_id="capacity", action="configure", item_id="plan", capacity_minutes=180,
|
||||
)
|
||||
|
||||
result = await main.update_today_plan(payload)
|
||||
|
||||
assert result["capacity_minutes"] == 180
|
||||
assert calls == [("timmy", [payload.model_dump()])]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_authenticated_today_api_uses_confirmed_account_and_csrf(monkeypatch, tmp_path):
|
||||
monkeypatch.setenv("STACKCHAIN_DASHBOARD_AUTH_MODE", "operator")
|
||||
@@ -246,9 +334,16 @@ async def test_authenticated_today_api_uses_confirmed_account_and_csrf(monkeypat
|
||||
assert changed.json() == {
|
||||
"revision": 2,
|
||||
"ids": ["issue:stackchain/dashboard:357:", "issue:stackchain/dashboard:359:"],
|
||||
"capacity_minutes": None,
|
||||
"estimates": {},
|
||||
"accepted_operation_ids": ["mobile-1", "mobile-2"],
|
||||
"duplicate_operation_ids": [],
|
||||
"rejected_operations": [],
|
||||
}
|
||||
assert fetched.json() == {"revision": 2, "ids": ["issue:stackchain/dashboard:357:", "issue:stackchain/dashboard:359:"]}
|
||||
assert fetched.json() == {
|
||||
"revision": 2,
|
||||
"ids": ["issue:stackchain/dashboard:357:", "issue:stackchain/dashboard:359:"],
|
||||
"capacity_minutes": None,
|
||||
"estimates": {},
|
||||
}
|
||||
assert fetched.headers["cache-control"] == "no-store"
|
||||
@@ -83,10 +83,51 @@ sync.enqueue('add', 'issue:r:1:');
|
||||
}
|
||||
|
||||
|
||||
def test_today_capacity_configuration_syncs_offline_and_adopts_remote_plan():
|
||||
script = f"""
|
||||
const createTodaySync = require({json.dumps(str(TODAY_SYNC))});
|
||||
const values = new Map(); let delivered; let adopted; let broadcast;
|
||||
const storage = {{get length(){{return values.size}},key:i=>[...values.keys()][i]||null,
|
||||
getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}};
|
||||
const sync = createTodaySync({{
|
||||
storage, getLogin:()=> 'timmy', createOperationId:()=> 'capacity-op',
|
||||
createChannel:()=>({{addEventListener() {{}},postMessage:plan=>{{broadcast=plan}},close() {{}}}}),
|
||||
fetchJson:async(_url, options={{}})=>{{
|
||||
if (!options.method) return {{revision:2,ids:['issue:r:1:'],capacity_minutes:null,estimates:{{}}}};
|
||||
delivered=JSON.parse(options.body).operations[0];
|
||||
return {{revision:3,ids:['issue:r:1:'],capacity_minutes:120,estimates:{{'issue:r:1:':60}},accepted_operation_ids:['capacity-op'],duplicate_operation_ids:[],rejected_operations:[]}};
|
||||
}},
|
||||
onRemotePlan:plan=>{{adopted=plan}}, onRemoteIds:()=>{{}}, onStatus:()=>{{}},
|
||||
}});
|
||||
sync.enqueueConfiguration(120, {{'issue:r:1:':60}});
|
||||
(async()=>{{await sync.flush();process.stdout.write(JSON.stringify({{delivered,adopted,broadcast,pending:sync.pending()}}));}})();
|
||||
"""
|
||||
assert json.loads(subprocess.run(
|
||||
["node", "-e", script], check=True, capture_output=True, text=True
|
||||
).stdout) == {
|
||||
"delivered": {
|
||||
"operation_id": "capacity-op", "action": "configure", "item_id": "plan",
|
||||
"direction": None, "capacity_minutes": 120,
|
||||
"estimates": {"issue:r:1:": 60}, "base_revision": 0,
|
||||
},
|
||||
"adopted": {
|
||||
"revision": 3, "ids": ["issue:r:1:"], "capacity_minutes": 120,
|
||||
"estimates": {"issue:r:1:": 60},
|
||||
"accepted_operation_ids": ["capacity-op"],
|
||||
"duplicate_operation_ids": [], "rejected_operations": [],
|
||||
},
|
||||
"broadcast": {
|
||||
"revision": 3, "ids": ["issue:r:1:"], "capacity_minutes": 120,
|
||||
"estimates": {"issue:r:1:": 60},
|
||||
},
|
||||
"pending": [],
|
||||
}
|
||||
|
||||
|
||||
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-v78" in source
|
||||
assert "stackchain-dashboard-shell-v79" in source
|
||||
assert "BASE + 'static/today-sync.js'" in source
|
||||
|
||||
|
||||
|
||||
@@ -64,6 +64,45 @@ process.stdout.write(JSON.stringify({{first, duplicate, full, timmy, persisted,
|
||||
}
|
||||
|
||||
|
||||
def test_today_capacity_metadata_is_account_scoped_and_prunes_removed_estimates():
|
||||
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)}};
|
||||
let login = 'timmy';
|
||||
const queue = createTodayWork({{storage, getLogin: () => login}});
|
||||
queue.replace(['issue:r:1:', 'issue:r:2:']);
|
||||
const saved = queue.replacePlanning({{capacity_minutes:120,estimates:{{'issue:r:1:':60,'issue:r:2:':45}}}});
|
||||
queue.replace(['issue:r:2:']);
|
||||
const timmy = queue.planning();
|
||||
login = 'alexander';
|
||||
const alexander = queue.planning();
|
||||
process.stdout.write(JSON.stringify({{saved,timmy,alexander}}));
|
||||
"""
|
||||
assert json.loads(run_node(script)) == {
|
||||
"saved": True,
|
||||
"timmy": {"capacity_minutes": 120, "estimates": {"issue:r:2:": 45}},
|
||||
"alexander": {"capacity_minutes": None, "estimates": {}},
|
||||
}
|
||||
|
||||
|
||||
def test_today_estimated_runway_follows_current_session_position():
|
||||
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.replace(['issue:r:1:','issue:r:2:','issue:r:3:']);
|
||||
queue.replacePlanning({{capacity_minutes:180,estimates:{{'issue:r:1:':30,'issue:r:2:':60,'issue:r:3:':45}}}});
|
||||
process.stdout.write(JSON.stringify({{first:queue.runway([item(1),item(2),item(3)],0),second:queue.runway([item(1),item(2),item(3)],1)}}));
|
||||
"""
|
||||
assert json.loads(run_node(script)) == {
|
||||
"first": {"current_minutes": 30, "remaining_minutes": 135},
|
||||
"second": {"current_minutes": 60, "remaining_minutes": 105},
|
||||
}
|
||||
|
||||
|
||||
def test_today_queue_exposes_reorder_boundaries_for_touch_controls():
|
||||
script = f"""
|
||||
const createTodayWork = require({json.dumps(str(TODAY_WORK))});
|
||||
|
||||
Reference in new issue
Block a user