feat: plan Search results into Week Ahead (Closes #1194)
This commit is contained in:
1 parent
5c74051aab
commit
ea1b5a77f5
13 files changed
+417
-2
No files matched your search
@@ -0,0 +1,43 @@
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
sync_api = pytest.importorskip("playwright.sync_api")
|
||||
|
||||
FRONTEND = Path(__file__).parents[2] / "frontend"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("width,height", [(320, 568), (390, 844)])
|
||||
def test_search_week_plan_sheet_fits_small_phones_with_touch_safe_controls(width, height):
|
||||
html = (FRONTEND / "index.html").read_text()
|
||||
with sync_api.sync_playwright() as playwright:
|
||||
try:
|
||||
browser = playwright.chromium.launch(headless=True)
|
||||
except Exception as error:
|
||||
pytest.skip(f"Chromium unavailable: {error}")
|
||||
page = browser.new_page(viewport={"width": width, "height": height})
|
||||
page.set_content(html)
|
||||
page.add_style_tag(path=FRONTEND / "dashboard.css")
|
||||
page.locator("#search-week-plan-days").evaluate(
|
||||
"""node => node.innerHTML = Array.from({length:7}, (_, index) =>
|
||||
`<button type="button" data-search-week-date="2026-08-${21+index}" aria-pressed="${index===0}">
|
||||
<strong>Day ${index+1}</strong><small>${index*30} of 120 min · ${index} planned</small>
|
||||
</button>`).join('')"""
|
||||
)
|
||||
page.locator("#search-week-plan-sheet").evaluate("node => node.hidden = false")
|
||||
page.locator("#search-week-plan-estimate").fill("45")
|
||||
|
||||
expect = sync_api.expect
|
||||
expect(page.locator("#search-week-plan-sheet")).to_be_visible()
|
||||
expect(page.locator("#confirm-search-week-plan")).to_be_visible()
|
||||
for button in page.locator("#search-week-plan-days button").all():
|
||||
bounds = button.bounding_box()
|
||||
assert bounds and bounds["height"] >= 44
|
||||
assert page.locator("#confirm-search-week-plan").bounding_box()["height"] >= 44
|
||||
assert page.locator("#cancel-search-week-plan").bounding_box()["height"] >= 44
|
||||
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
|
||||
assert page.locator(".search-week-plan-panel").evaluate(
|
||||
"node => node.scrollWidth <= node.clientWidth"
|
||||
)
|
||||
browser.close()
|
||||
@@ -0,0 +1,101 @@
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
FRONTEND = Path(__file__).parents[1] / "frontend"
|
||||
MODULE = FRONTEND / "search-week-plan.js"
|
||||
|
||||
|
||||
def run_planner(scenario: str) -> dict:
|
||||
harness = f"""
|
||||
const createSearchWeekPlan = require({json.dumps(str(MODULE))});
|
||||
(async()=>{{ {scenario} }})().catch(error=>{{console.error(error);process.exit(1);}});
|
||||
"""
|
||||
completed = subprocess.run(["node", "-e", harness], check=True, capture_output=True, text=True)
|
||||
return json.loads(completed.stdout)
|
||||
|
||||
|
||||
def test_search_week_plan_previews_seven_days_and_requires_explicit_overload_confirmation():
|
||||
result = run_planner("""
|
||||
let claims=0,stages=0;
|
||||
const days=Array.from({length:7},(_,index)=>({date:`2026-08-${21+index}`,label:`Day ${index+1}`}));
|
||||
const week={load:async()=>({}),dates:()=>days,day:date=>date==='2026-08-21'
|
||||
? {plan_date:date,ids:['existing'],capacity_minutes:60,estimates:{existing:50}}
|
||||
: {plan_date:date,ids:[],capacity_minutes:90,estimates:{}},
|
||||
placement:()=>null,place:()=>{stages+=1;return true;},flush:async()=>({revision:2})};
|
||||
const planner=createSearchWeekPlan({week,claim:async item=>{claims+=1;return {...item,assigned_to_me:true,claimable:false};},
|
||||
accept:item=>item,identity:item=>`issue:${item.repository}:${item.number}:`});
|
||||
const detail={kind:'issue',state:'open',repository:'stackchain/dashboard',number:42,claimable:true};
|
||||
const preview=await planner.preview(detail);
|
||||
const invalid=await planner.plan(detail,{date:'2026-08-21',estimate:0});
|
||||
const overload=await planner.plan(detail,{date:'2026-08-21',estimate:30});
|
||||
console.log(JSON.stringify({preview,invalid,overload,claims,stages}));
|
||||
""")
|
||||
|
||||
assert len(result["preview"]["days"]) == 7
|
||||
assert result["preview"]["days"][0]["planned_minutes"] == 50
|
||||
assert result["preview"]["days"][0]["capacity_minutes"] == 60
|
||||
assert result["invalid"] == {"status": "estimate-required"}
|
||||
assert result["overload"]["status"] == "overload-confirmation-required"
|
||||
assert result["overload"]["planned_minutes"] == 80
|
||||
assert result["claims"] == 0
|
||||
assert result["stages"] == 0
|
||||
|
||||
|
||||
def test_search_week_plan_assigns_once_then_durably_places_canonical_issue():
|
||||
result = run_planner("""
|
||||
let release,claims=0,places=0,flushes=0;
|
||||
const week={load:async()=>({}),dates:()=>[{date:'2026-08-22',label:'Sat, Aug 22'}],
|
||||
day:date=>({plan_date:date,ids:[],capacity_minutes:120,estimates:{}}),placement:()=>null,
|
||||
place:(id,date,estimate)=>{places+=1;return {id,date,estimate};},flush:async()=>{flushes+=1;return {revision:3};}};
|
||||
const planner=createSearchWeekPlan({week,
|
||||
claim:async item=>{claims+=1;await new Promise(resolve=>{release=resolve});return {...item,assigned_to_me:true,claimable:false,title:'Canonical'};},
|
||||
accept:item=>({...item,accepted:true}),identity:item=>`issue:${item.repository}:${item.number}:`});
|
||||
const detail={kind:'issue',state:'open',repository:'stackchain/dashboard',number:42,claimable:true};
|
||||
const first=planner.plan(detail,{date:'2026-08-22',estimate:45});
|
||||
const second=planner.plan(detail,{date:'2026-08-22',estimate:45});
|
||||
await Promise.resolve();release();
|
||||
const outcomes=await Promise.all([first,second]);
|
||||
console.log(JSON.stringify({outcomes,claims,places,flushes}));
|
||||
""")
|
||||
|
||||
assert result["claims"] == 1
|
||||
assert result["places"] == 1
|
||||
assert result["flushes"] == 1
|
||||
assert result["outcomes"][0] == result["outcomes"][1]
|
||||
assert result["outcomes"][0]["status"] == "planned"
|
||||
assert result["outcomes"][0]["item"]["accepted"] is True
|
||||
|
||||
|
||||
def test_search_week_plan_reports_existing_placement_and_partial_assignment_truthfully():
|
||||
result = run_planner("""
|
||||
const base={load:async()=>({}),dates:()=>[{date:'2026-08-22',label:'Sat'}],
|
||||
day:date=>({plan_date:date,ids:[],capacity_minutes:120,estimates:{}}),flush:async()=>({})};
|
||||
const detail={kind:'issue',state:'open',repository:'stackchain/dashboard',number:42,claimable:true};
|
||||
const existing=createSearchWeekPlan({week:{...base,placement:()=>({date:'2026-08-23',estimate:30}),place:()=>true},
|
||||
claim:async item=>item,accept:item=>item,identity:()=> 'work'});
|
||||
const blocked=await existing.plan(detail,{date:'2026-08-22',estimate:45});
|
||||
const partial=createSearchWeekPlan({week:{...base,placement:()=>null,place:()=>false},
|
||||
claim:async item=>({...item,assigned_to_me:true,claimable:false}),accept:item=>item,identity:()=> 'work'});
|
||||
const failed=await partial.plan(detail,{date:'2026-08-22',estimate:45});
|
||||
console.log(JSON.stringify({blocked,failed}));
|
||||
""")
|
||||
|
||||
assert result["blocked"] == {"status": "already-planned", "date": "2026-08-23", "estimate": 30}
|
||||
assert result["failed"]["status"] == "assigned-not-planned"
|
||||
|
||||
|
||||
def test_search_week_plan_moves_without_duplicates_and_keeps_pending_sync_on_network_failure():
|
||||
result = run_planner("""
|
||||
const week={load:async()=>({}),dates:()=>[{date:'2026-08-22',label:'Sat'}],
|
||||
day:date=>({plan_date:date,ids:[],capacity_minutes:120,estimates:{}}),
|
||||
placement:()=>({date:'2026-08-21',estimate:30}),place:()=>true,
|
||||
flush:async()=>{throw new Error('offline');}};
|
||||
const planner=createSearchWeekPlan({week,claim:async item=>item,accept:item=>item,identity:()=> 'work'});
|
||||
const outcome=await planner.plan({kind:'issue',state:'open',assigned_to_me:true},
|
||||
{date:'2026-08-22',estimate:45,move:true});
|
||||
console.log(JSON.stringify(outcome));
|
||||
""")
|
||||
|
||||
assert result == {"status": "planned-pending", "date": "2026-08-22", "estimate": 45}
|
||||
@@ -0,0 +1,51 @@
|
||||
from pathlib import Path
|
||||
|
||||
from src.frontend_bundle import build_frontend
|
||||
|
||||
|
||||
ROOT = Path(__file__).parents[1]
|
||||
FRONTEND = ROOT / "frontend"
|
||||
|
||||
|
||||
def test_search_preview_exposes_accessible_plan_ahead_sheet_and_packaged_controller():
|
||||
html = (FRONTEND / "index.html").read_text()
|
||||
build = build_frontend(FRONTEND)
|
||||
|
||||
assert 'id="plan-search-result"' in html
|
||||
assert 'id="search-week-plan-sheet" role="dialog" aria-modal="true"' in html
|
||||
assert 'aria-labelledby="search-week-plan-title"' in html
|
||||
assert 'id="search-week-plan-days"' in html
|
||||
assert 'id="search-week-plan-estimate"' in html
|
||||
assert 'id="confirm-search-week-plan"' in html
|
||||
assert 'id="cancel-search-week-plan"' in html
|
||||
assert "static/search-week-plan.js" in build.page_sources
|
||||
assert b"createSearchWeekPlan" in build.feature_bundles["planning"].runtime_bytes
|
||||
|
||||
|
||||
def test_search_plan_ahead_ui_connects_eligibility_preview_and_truthful_outcomes():
|
||||
dashboard = (FRONTEND / "dashboard.js").read_text()
|
||||
source = (FRONTEND / "search-week-plan.js").read_text()
|
||||
|
||||
assert "const searchWeekPlan = createSearchWeekPlan" in dashboard
|
||||
assert "createSearchWeekPlanUI" in dashboard
|
||||
assert "planButton.hidden = !searchWeekPlan.eligible(detail)" in dashboard
|
||||
assert "await planner.preview(detail)" in source
|
||||
assert "overload-confirmation-required" in source
|
||||
assert "assigned-not-planned" in source
|
||||
assert "planned-pending" in source
|
||||
assert "Already planned for" in source
|
||||
assert "planner.pending()" in source
|
||||
assert "searchWeekPlan:true" in source
|
||||
assert "if(!qs('#search-week-plan-sheet').hidden)" in source
|
||||
assert "window.history.back()" in source
|
||||
|
||||
|
||||
def test_search_plan_ahead_sheet_is_phone_safe_and_touch_sized():
|
||||
css = (FRONTEND / "dashboard.css").read_text()
|
||||
|
||||
assert ".search-week-plan-sheet" in css
|
||||
assert "padding-bottom:calc(18px + env(safe-area-inset-bottom))" in css
|
||||
assert ".search-week-plan-days button" in css
|
||||
assert "min-height:44px" in css
|
||||
assert "overflow-x:hidden" in css
|
||||
assert "@media (max-width:359px)" in css
|
||||
@@ -1329,6 +1329,7 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
|
||||
"/dashboard/static/plan-today-preview.js",
|
||||
"/dashboard/static/tomorrow-plan.js",
|
||||
"/dashboard/static/week-plan.js",
|
||||
"/dashboard/static/search-week-plan.js",
|
||||
"/dashboard/static/today-sync.js",
|
||||
"/dashboard/static/today-rollover.js",
|
||||
"/dashboard/static/update-ownership.js",
|
||||
|
||||
@@ -95,6 +95,32 @@ console.log(JSON.stringify({before,moved,after,pending:week.pending()}));
|
||||
assert result["pending"]["days"][2]["estimates"] == {"shared": 30}
|
||||
|
||||
|
||||
def test_week_controller_places_new_work_or_explicitly_moves_existing_work_without_duplicates():
|
||||
result = run_controller("""
|
||||
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 week=createWeekPlan({storage,getLogin:()=> 'timmy',fetchJson:async()=>({}),
|
||||
localDate:()=> '2026-08-20',timeZone:()=> 'UTC'});
|
||||
week.adopt({revision:6,timezone:'UTC',days:[
|
||||
{plan_date:'2026-08-21',ids:['existing'],capacity_minutes:60,estimates:{existing:30}},
|
||||
{plan_date:'2026-08-22',ids:['other'],capacity_minutes:120,estimates:{other:45}}
|
||||
]});
|
||||
const before=week.placement('existing');
|
||||
const refused=week.place('existing','2026-08-22',50);
|
||||
const moved=week.place('existing','2026-08-22',50,{move:true});
|
||||
const added=week.place('new','2026-08-23',25);
|
||||
console.log(JSON.stringify({before,refused,moved,added,state:week.state(),pending:week.pending()}));
|
||||
""")
|
||||
|
||||
assert result["before"] == {"date": "2026-08-21", "estimate": 30}
|
||||
assert result["refused"] is False
|
||||
assert result["moved"] is True
|
||||
assert result["added"] is True
|
||||
assert [day["ids"] for day in result["state"]["days"]] == [[], ["other", "existing"], ["new"]]
|
||||
assert result["state"]["days"][1]["estimates"]["existing"] == 50
|
||||
assert result["pending"]["days"] == result["state"]["days"]
|
||||
|
||||
|
||||
def test_week_workflow_saves_and_advances_without_closing_the_planner():
|
||||
result = run_controller("""
|
||||
const createWorkflow=createWeekPlan.Workflow;
|
||||
|
||||
Reference in new issue
Block a user