feat: sync first-task completion across devices (Closes #1148)
This commit is contained in:
1 parent
e2be14d7c7
commit
fa47ded3ab
7 files changed
+184
-12
No files matched your search
@@ -118,6 +118,34 @@ process.stdout.write(JSON.stringify({
|
||||
}
|
||||
|
||||
|
||||
def test_first_task_adopts_remote_completion_without_crossing_accounts():
|
||||
result = run_node(
|
||||
"""
|
||||
const values = new Map(); let login='timmy'; let todayActive=false;
|
||||
const storage={getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value)};
|
||||
const controller=createFirstTask({
|
||||
storage,getLogin:()=>login,hasWork:()=>false,isTodayActive:()=>todayActive,isOnline:()=>true,
|
||||
mediaQuery:{matches:true},sheet:new Element(),title:new Element(),coach:new Element(),receipt:new Element(),
|
||||
findButton:new Element(),createButton:new Element(),setupButton:new Element(),closeButton:new Element(),
|
||||
status:new Element(),
|
||||
});
|
||||
const adopted=controller.adoptRemote('complete');
|
||||
const timmy={required:controller.required(),stored:values.get('stackchain.first-task.v1:timmy')};
|
||||
login='alexander'; const alexanderRequired=controller.required();
|
||||
todayActive=true; controller.refresh(); controller.completeOutcome();
|
||||
process.stdout.write(JSON.stringify({adopted,timmy,alexanderRequired,
|
||||
alexander:values.get('stackchain.first-task.v1:alexander')}));
|
||||
"""
|
||||
)
|
||||
|
||||
assert result == {
|
||||
"adopted": True,
|
||||
"timmy": {"required": False, "stored": "complete"},
|
||||
"alexanderRequired": True,
|
||||
"alexander": "complete",
|
||||
}
|
||||
|
||||
|
||||
def test_first_task_activation_routes_existing_flows_and_keeps_create_available_offline():
|
||||
result = run_node(
|
||||
"""
|
||||
@@ -174,6 +202,7 @@ async def test_dashboard_renders_and_wires_phone_safe_first_task_activation():
|
||||
assert '<script src="static/mobile-first-task.js"></script>' in html
|
||||
assert "const mobileFirstTask = createMobileFirstTask({" in html
|
||||
assert "isTodayActive: () => workSession.checkpointed()" in html
|
||||
|
||||
assert "shouldActivate: () => mobileFirstTask.required()" in html
|
||||
assert "openActivation: () => mobileFirstTask.open()" in html
|
||||
assert "mobileFirstTask.refresh()" in html
|
||||
|
||||
@@ -304,6 +304,36 @@ def test_initialized_today_reads_remain_available_during_a_planning_write(tmp_pa
|
||||
writer.close()
|
||||
|
||||
|
||||
def test_first_task_completion_is_monotonic_durable_and_account_scoped(tmp_path):
|
||||
path = tmp_path / "today.sqlite3"
|
||||
key = b"f" * 32
|
||||
store = TodayStore(path, encryption_key=key)
|
||||
store.apply("timmy", "plan-change", "add", "issue:r:1:")
|
||||
|
||||
completed = store.apply_batch("Timmy", [{
|
||||
"operation_id": "first-task-complete",
|
||||
"action": "activate",
|
||||
"item_id": "first-task",
|
||||
"activation_state": "complete",
|
||||
"base_revision": 0,
|
||||
}])
|
||||
replayed_coaching = store.apply_batch("timmy", [{
|
||||
"operation_id": "late-coaching",
|
||||
"action": "activate",
|
||||
"item_id": "first-task",
|
||||
"activation_state": "coaching",
|
||||
"base_revision": 0,
|
||||
}])
|
||||
|
||||
assert completed["first_task_state"] == "complete"
|
||||
assert replayed_coaching["first_task_state"] == "complete"
|
||||
assert TodayStore(path, encryption_key=key).get("timmy")["first_task_state"] == "complete"
|
||||
assert "first_task_state" not in store.get("alexander")
|
||||
retained = path.read_bytes()
|
||||
assert b'first_task_state' not in retained
|
||||
assert b'complete' not in retained
|
||||
|
||||
|
||||
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)
|
||||
@@ -499,6 +529,24 @@ def test_today_api_model_accepts_bounded_capacity_configuration():
|
||||
}
|
||||
|
||||
|
||||
def test_today_api_model_accepts_only_bounded_first_task_activation():
|
||||
operation = main.TodayOperation(
|
||||
operation_id="activation", action="activate", item_id="first-task",
|
||||
activation_state="complete", base_revision=7,
|
||||
)
|
||||
|
||||
assert operation.model_dump() == {
|
||||
"operation_id": "activation", "action": "activate", "item_id": "first-task",
|
||||
"direction": None, "base_revision": 7, "capacity_minutes": None,
|
||||
"estimates": {}, "activation_state": "complete",
|
||||
}
|
||||
with pytest.raises(ValueError):
|
||||
main.TodayOperation(
|
||||
operation_id="bad", action="activate", item_id="another-flow",
|
||||
activation_state="complete",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_single_capacity_configuration_uses_atomic_batch_path(monkeypatch):
|
||||
async def user():
|
||||
|
||||
@@ -188,6 +188,43 @@ sync.enqueueConfiguration(120, {{'issue:r:1:':60}});
|
||||
}
|
||||
|
||||
|
||||
def test_first_task_activation_uses_today_outbox_and_adopts_remote_completion():
|
||||
script = f"""
|
||||
const createTodaySync = require({json.dumps(str(TODAY_SYNC))});
|
||||
const values = new Map(); let sequence=0; const adopted=[]; const delivered=[]; const listeners={{}};
|
||||
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:()=> 'activation-' + (++sequence),
|
||||
fetchJson:async(_url, options={{}})=>{{
|
||||
if (!options.method) return {{revision:4,ids:[],first_task_state:'complete'}};
|
||||
const operations=JSON.parse(options.body).operations; delivered.push(...operations);
|
||||
return {{revision:5,ids:[],first_task_state:'complete',accepted_operation_ids:operations.map(x=>x.operation_id)}};
|
||||
}},
|
||||
onRemotePlan:plan=>adopted.push(plan.first_task_state), onRemoteIds:()=>{{}}, onStatus:()=>{{}},
|
||||
}});
|
||||
sync.startLifecycle({{window:{{addEventListener:(name,callback)=>listeners[name]=callback}},document:{{addEventListener(){{}}}}}});
|
||||
listeners['stackchain:first-task-complete']();
|
||||
listeners['stackchain:first-task-complete']();
|
||||
(async()=>{{await sync.flush();process.stdout.write(JSON.stringify({{
|
||||
delivered,adopted,pending:sync.pending(),hydrated:values.get('stackchain.first-task.v1:timmy')
|
||||
}}));}})();
|
||||
"""
|
||||
result = json.loads(subprocess.run(
|
||||
["node", "-e", script], check=True, capture_output=True, text=True
|
||||
).stdout)
|
||||
|
||||
assert result == {
|
||||
"delivered": [{
|
||||
"operation_id": "activation-1", "action": "activate", "item_id": "first-task",
|
||||
"direction": None, "activation_state": "complete", "base_revision": 0,
|
||||
}],
|
||||
"adopted": ["complete"],
|
||||
"pending": [],
|
||||
"hydrated": "complete",
|
||||
}
|
||||
|
||||
|
||||
def test_inflight_today_drain_ships_in_a_new_offline_shell():
|
||||
source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
|
||||
|
||||
|
||||
Reference in new issue
Block a user