Merge pull request 'Continue a timed Today break across devices' (#1039) from timmy/1038-cross-device-today-break into main
This commit is contained in:
commit
4a85f02eae
|
|
@ -20,12 +20,13 @@ function createTodaySessionSync({
|
||||||
|
|
||||||
function adopt(session) {
|
function adopt(session) {
|
||||||
if (!session || !Number.isInteger(session.revision)) return null;
|
if (!session || !Number.isInteger(session.revision)) return null;
|
||||||
const previousOwned = current?.device_id === deviceId() && current?.running;
|
const previousOwned = current?.device_id === deviceId() &&
|
||||||
|
(current?.running || Number.isFinite(current?.break_deadline_at));
|
||||||
current = session;
|
current = session;
|
||||||
if (session.device_id === deviceId()) {
|
if (session.device_id === deviceId()) {
|
||||||
ownedRevision = session.revision;
|
ownedRevision = session.revision;
|
||||||
onRemote(null);
|
onRemote(null);
|
||||||
} else if (session.running && session.identity) {
|
} else if ((session.running || Number.isFinite(session.break_deadline_at)) && session.identity) {
|
||||||
if (previousOwned) {
|
if (previousOwned) {
|
||||||
timer?.pause?.();
|
timer?.pause?.();
|
||||||
onTransferred(session);
|
onTransferred(session);
|
||||||
|
|
@ -53,15 +54,18 @@ function createTodaySessionSync({
|
||||||
|
|
||||||
function claim() {
|
function claim() {
|
||||||
return serialize(async () => {
|
return serialize(async () => {
|
||||||
if (!current?.running || !current.identity || current.device_id === deviceId() || !deviceId()) return null;
|
if ((!current?.running && !Number.isFinite(current?.break_deadline_at)) ||
|
||||||
|
!current.identity || current.device_id === deviceId() || !deviceId()) return null;
|
||||||
try {
|
try {
|
||||||
onStatus('syncing');
|
onStatus('syncing');
|
||||||
|
const body = {
|
||||||
|
base_revision:current.revision, device_id:deviceId(), identity:current.identity,
|
||||||
|
elapsed_ms:current.elapsed_ms, running:true,
|
||||||
|
};
|
||||||
|
if (Object.hasOwn(current, 'break_deadline_at')) body.break_deadline_at = null;
|
||||||
const session = await fetchJson(endpoint, {
|
const session = await fetchJson(endpoint, {
|
||||||
method:'PATCH', headers:{'Content-Type':'application/json'},
|
method:'PATCH', headers:{'Content-Type':'application/json'},
|
||||||
body:JSON.stringify({
|
body:JSON.stringify(body),
|
||||||
base_revision:current.revision, device_id:deviceId(), identity:current.identity,
|
|
||||||
elapsed_ms:current.elapsed_ms, running:true,
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
adopt(session);
|
adopt(session);
|
||||||
timer?.adopt?.(session.identity, session.elapsed_ms, session.running);
|
timer?.adopt?.(session.identity, session.elapsed_ms, session.running);
|
||||||
|
|
@ -82,13 +86,18 @@ function createTodaySessionSync({
|
||||||
if (!snapshot?.identity || !deviceId()) return null;
|
if (!snapshot?.identity || !deviceId()) return null;
|
||||||
try {
|
try {
|
||||||
onStatus('syncing');
|
onStatus('syncing');
|
||||||
|
const body = {
|
||||||
|
base_revision:ownedRevision, device_id:deviceId(), identity:snapshot.identity,
|
||||||
|
elapsed_ms:Math.max(0, Math.floor(Number(snapshot.elapsed_ms) || 0)),
|
||||||
|
running:Boolean(snapshot.running),
|
||||||
|
};
|
||||||
|
if (Object.hasOwn(snapshot, 'break_deadline_at') || Object.hasOwn(current || {}, 'break_deadline_at')) {
|
||||||
|
body.break_deadline_at = Number.isFinite(snapshot.break_deadline_at) ?
|
||||||
|
Math.floor(snapshot.break_deadline_at) : null;
|
||||||
|
}
|
||||||
const session = await fetchJson(endpoint, {
|
const session = await fetchJson(endpoint, {
|
||||||
method:'PATCH', headers:{'Content-Type':'application/json'},
|
method:'PATCH', headers:{'Content-Type':'application/json'},
|
||||||
body:JSON.stringify({
|
body:JSON.stringify(body),
|
||||||
base_revision:ownedRevision, device_id:deviceId(), identity:snapshot.identity,
|
|
||||||
elapsed_ms:Math.max(0, Math.floor(Number(snapshot.elapsed_ms) || 0)),
|
|
||||||
running:Boolean(snapshot.running),
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
adopt(session);
|
adopt(session);
|
||||||
return session;
|
return session;
|
||||||
|
|
@ -146,6 +155,17 @@ function createTodaySessionSync({
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function todaySessionHandoffSummary(session, item) {
|
||||||
|
if (Number.isFinite(session?.break_deadline_at)) {
|
||||||
|
const end = new Date(session.break_deadline_at).toLocaleTimeString([], {
|
||||||
|
hour:'numeric', minute:'2-digit',
|
||||||
|
});
|
||||||
|
return `On break until ${end} · ready to resume here`;
|
||||||
|
}
|
||||||
|
const minutes = Math.max(0, Math.floor(Number(session?.elapsed_ms || 0) / 60000));
|
||||||
|
return `${item?.title || 'Current Today item'} · ${minutes} min elapsed`;
|
||||||
|
}
|
||||||
|
|
||||||
function attachTodaySessionHandoff({
|
function attachTodaySessionHandoff({
|
||||||
fetchJson, storage, timer, qs, items, identity, selectToday, startItem, announce, renderTimer,
|
fetchJson, storage, timer, qs, items, identity, selectToday, startItem, announce, renderTimer,
|
||||||
}) {
|
}) {
|
||||||
|
|
@ -176,9 +196,9 @@ function attachTodaySessionHandoff({
|
||||||
handoff.hidden = !session;
|
handoff.hidden = !session;
|
||||||
if (!session) return;
|
if (!session) return;
|
||||||
const item = items().find(entry => identity(entry) === session.identity);
|
const item = items().find(entry => identity(entry) === session.identity);
|
||||||
const minutes = Math.max(0, Math.floor(Number(session.elapsed_ms || 0) / 60000));
|
qs('#today-session-handoff-summary').textContent = todaySessionHandoffSummary(session, item);
|
||||||
qs('#today-session-handoff-summary').textContent =
|
qs('#continue-today-session').textContent = Number.isFinite(session.break_deadline_at) ?
|
||||||
`${item?.title || 'Current Today item'} · ${minutes} min elapsed`;
|
'Resume Today here' : 'Continue here';
|
||||||
},
|
},
|
||||||
onTransferred:() => {
|
onTransferred:() => {
|
||||||
showSessionStatus('Session continued on another device.');
|
showSessionStatus('Session continued on another device.');
|
||||||
|
|
@ -201,5 +221,5 @@ function attachTodaySessionHandoff({
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof module !== 'undefined' && module.exports) module.exports = {
|
if (typeof module !== 'undefined' && module.exports) module.exports = {
|
||||||
createTodaySessionSync, attachTodaySessionHandoff,
|
createTodaySessionSync, attachTodaySessionHandoff, todaySessionHandoffSummary,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -61,10 +61,17 @@ function createTodayTimer({ storage, getLogin, now = () => Date.now(), onChange
|
||||||
const state = read();
|
const state = read();
|
||||||
const selected = identity || state.active_identity;
|
const selected = identity || state.active_identity;
|
||||||
const entry = state.entries[selected];
|
const entry = state.entries[selected];
|
||||||
if (!selected || !entry) return { identity:selected, elapsed_ms:0, running:false };
|
const result = !selected || !entry ?
|
||||||
const elapsed = Math.max(0, Number(entry.elapsed_ms) || 0) + (entry.running ?
|
{ identity:selected, elapsed_ms:0, running:false } :
|
||||||
Math.max(0, now() - Number(entry.started_at ?? now())) : 0);
|
{
|
||||||
return { identity:selected, elapsed_ms:elapsed, running:Boolean(entry.running) };
|
identity:selected,
|
||||||
|
elapsed_ms:Math.max(0, Number(entry.elapsed_ms) || 0) + (entry.running ?
|
||||||
|
Math.max(0, now() - Number(entry.started_at ?? now())) : 0),
|
||||||
|
running:Boolean(entry.running),
|
||||||
|
};
|
||||||
|
const timedBreak = validBreak(state);
|
||||||
|
if (timedBreak?.identity === selected) result.break_deadline_at = timedBreak.deadline_at;
|
||||||
|
return result;
|
||||||
};
|
};
|
||||||
return {
|
return {
|
||||||
adopt(identity, elapsedMs, running) {
|
adopt(identity, elapsedMs, running) {
|
||||||
|
|
|
||||||
|
|
@ -594,6 +594,13 @@ class TodaySessionUpdate(BaseModel):
|
||||||
identity: str = Field(max_length=500)
|
identity: str = Field(max_length=500)
|
||||||
elapsed_ms: int = Field(ge=0, le=7 * 24 * 60 * 60 * 1000)
|
elapsed_ms: int = Field(ge=0, le=7 * 24 * 60 * 60 * 1000)
|
||||||
running: bool
|
running: bool
|
||||||
|
break_deadline_at: int | None = Field(default=None, ge=0, le=10_000_000_000_000)
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def validate_break_is_paused(self):
|
||||||
|
if self.running and self.break_deadline_at is not None:
|
||||||
|
raise ValueError("break must remain paused")
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
class TodayRecapTimeLog(TodayRecap):
|
class TodayRecapTimeLog(TodayRecap):
|
||||||
|
|
|
||||||
|
|
@ -44,7 +44,7 @@ class TodayStore:
|
||||||
|
|
||||||
def _initialize(self) -> None:
|
def _initialize(self) -> None:
|
||||||
connection = connect_private_sqlite(self.path, timeout=self.timeout)
|
connection = connect_private_sqlite(self.path, timeout=self.timeout)
|
||||||
if connection.execute("PRAGMA user_version").fetchone()[0] >= 2:
|
if connection.execute("PRAGMA user_version").fetchone()[0] >= 3:
|
||||||
connection.close()
|
connection.close()
|
||||||
return
|
return
|
||||||
connection.execute("PRAGMA journal_mode=WAL")
|
connection.execute("PRAGMA journal_mode=WAL")
|
||||||
|
|
@ -123,11 +123,15 @@ class TodayStore:
|
||||||
identity TEXT NOT NULL,
|
identity TEXT NOT NULL,
|
||||||
elapsed_ms INTEGER NOT NULL,
|
elapsed_ms INTEGER NOT NULL,
|
||||||
running INTEGER NOT NULL,
|
running INTEGER NOT NULL,
|
||||||
|
break_deadline_at INTEGER,
|
||||||
updated_at REAL NOT NULL
|
updated_at REAL NOT NULL
|
||||||
)
|
)
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
connection.execute("PRAGMA user_version = 2")
|
session_columns = {row[1] for row in connection.execute("PRAGMA table_info(today_sessions)")}
|
||||||
|
if "break_deadline_at" not in session_columns:
|
||||||
|
connection.execute("ALTER TABLE today_sessions ADD COLUMN break_deadline_at INTEGER")
|
||||||
|
connection.execute("PRAGMA user_version = 3")
|
||||||
connection.commit()
|
connection.commit()
|
||||||
connection.close()
|
connection.close()
|
||||||
|
|
||||||
|
|
@ -188,13 +192,13 @@ class TodayStore:
|
||||||
def _empty_session() -> dict:
|
def _empty_session() -> dict:
|
||||||
return {
|
return {
|
||||||
"revision": 0, "device_id": "", "identity": "",
|
"revision": 0, "device_id": "", "identity": "",
|
||||||
"elapsed_ms": 0, "running": False, "updated_at": None,
|
"elapsed_ms": 0, "running": False, "break_deadline_at": None, "updated_at": None,
|
||||||
}
|
}
|
||||||
|
|
||||||
def get_session(self, login: str) -> dict:
|
def get_session(self, login: str) -> dict:
|
||||||
with self._connect() as connection:
|
with self._connect() as connection:
|
||||||
row = connection.execute(
|
row = connection.execute(
|
||||||
"SELECT revision, device_id, identity, elapsed_ms, running, updated_at "
|
"SELECT revision, device_id, identity, elapsed_ms, running, break_deadline_at, updated_at "
|
||||||
"FROM today_sessions WHERE login = ?",
|
"FROM today_sessions WHERE login = ?",
|
||||||
(self._normalize_login(login),),
|
(self._normalize_login(login),),
|
||||||
).fetchone()
|
).fetchone()
|
||||||
|
|
@ -202,36 +206,38 @@ class TodayStore:
|
||||||
return self._empty_session()
|
return self._empty_session()
|
||||||
return {
|
return {
|
||||||
"revision": int(row[0]), "device_id": row[1], "identity": row[2],
|
"revision": int(row[0]), "device_id": row[1], "identity": row[2],
|
||||||
"elapsed_ms": int(row[3]), "running": bool(row[4]), "updated_at": row[5],
|
"elapsed_ms": int(row[3]), "running": bool(row[4]),
|
||||||
|
"break_deadline_at": row[5], "updated_at": row[6],
|
||||||
}
|
}
|
||||||
|
|
||||||
def update_session(
|
def update_session(
|
||||||
self, login: str, *, base_revision: int, device_id: str,
|
self, login: str, *, base_revision: int, device_id: str,
|
||||||
identity: str, elapsed_ms: int, running: bool,
|
identity: str, elapsed_ms: int, running: bool, break_deadline_at: int | None = None,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
login = self._normalize_login(login)
|
login = self._normalize_login(login)
|
||||||
updated_at = self.clock()
|
updated_at = self.clock()
|
||||||
with self._connect() as connection:
|
with self._connect() as connection:
|
||||||
connection.execute("BEGIN IMMEDIATE")
|
connection.execute("BEGIN IMMEDIATE")
|
||||||
current = connection.execute(
|
current = connection.execute(
|
||||||
"SELECT revision, device_id, identity, elapsed_ms, running, updated_at "
|
"SELECT revision, device_id, identity, elapsed_ms, running, break_deadline_at, updated_at "
|
||||||
"FROM today_sessions WHERE login = ?", (login,)
|
"FROM today_sessions WHERE login = ?", (login,)
|
||||||
).fetchone()
|
).fetchone()
|
||||||
current_revision = int(current[0]) if current else 0
|
current_revision = int(current[0]) if current else 0
|
||||||
if base_revision != current_revision:
|
if base_revision != current_revision:
|
||||||
session = self._empty_session() if current is None else {
|
session = self._empty_session() if current is None else {
|
||||||
"revision": current_revision, "device_id": current[1], "identity": current[2],
|
"revision": current_revision, "device_id": current[1], "identity": current[2],
|
||||||
"elapsed_ms": int(current[3]), "running": bool(current[4]), "updated_at": current[5],
|
"elapsed_ms": int(current[3]), "running": bool(current[4]),
|
||||||
|
"break_deadline_at": current[5], "updated_at": current[6],
|
||||||
}
|
}
|
||||||
raise TodaySessionConflict(session)
|
raise TodaySessionConflict(session)
|
||||||
revision = current_revision + 1
|
revision = current_revision + 1
|
||||||
connection.execute(
|
connection.execute(
|
||||||
"INSERT INTO today_sessions(login, revision, device_id, identity, elapsed_ms, running, updated_at) "
|
"INSERT INTO today_sessions(login, revision, device_id, identity, elapsed_ms, running, break_deadline_at, updated_at) "
|
||||||
"VALUES (?, ?, ?, ?, ?, ?, ?) "
|
"VALUES (?, ?, ?, ?, ?, ?, ?, ?) "
|
||||||
"ON CONFLICT(login) DO UPDATE SET revision=excluded.revision, device_id=excluded.device_id, "
|
"ON CONFLICT(login) DO UPDATE SET revision=excluded.revision, device_id=excluded.device_id, "
|
||||||
"identity=excluded.identity, elapsed_ms=excluded.elapsed_ms, running=excluded.running, "
|
"identity=excluded.identity, elapsed_ms=excluded.elapsed_ms, running=excluded.running, "
|
||||||
"updated_at=excluded.updated_at",
|
"break_deadline_at=excluded.break_deadline_at, updated_at=excluded.updated_at",
|
||||||
(login, revision, device_id, identity, elapsed_ms, int(running), updated_at),
|
(login, revision, device_id, identity, elapsed_ms, int(running), break_deadline_at, updated_at),
|
||||||
)
|
)
|
||||||
return self.get_session(login)
|
return self.get_session(login)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -37,7 +37,10 @@ process.stdout.write(JSON.stringify({started,paused,restored}));
|
||||||
"deadline_at": 1012000,
|
"deadline_at": 1012000,
|
||||||
"expired": False,
|
"expired": False,
|
||||||
},
|
},
|
||||||
"paused": {"identity": "issue:r:42:", "elapsed_ms": 12000, "running": False},
|
"paused": {
|
||||||
|
"identity": "issue:r:42:", "elapsed_ms": 12000, "running": False,
|
||||||
|
"break_deadline_at": 1012000,
|
||||||
|
},
|
||||||
"restored": {
|
"restored": {
|
||||||
"identity": "issue:r:42:",
|
"identity": "issue:r:42:",
|
||||||
"deadline_at": 1012000,
|
"deadline_at": 1012000,
|
||||||
|
|
@ -46,6 +49,22 @@ process.stdout.write(JSON.stringify({started,paused,restored}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_timed_break_snapshot_carries_deadline_for_cross_device_sync():
|
||||||
|
script = TIMER.read_text() + r"""
|
||||||
|
const values = new Map();
|
||||||
|
const storage = {getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value)};
|
||||||
|
const timer = createTodayTimer({storage,getLogin:()=> 'timmy',now:()=>100000});
|
||||||
|
timer.activate('issue:r:42:');
|
||||||
|
timer.startBreak(15);
|
||||||
|
process.stdout.write(JSON.stringify(timer.snapshot()));
|
||||||
|
"""
|
||||||
|
|
||||||
|
assert run_node(script) == {
|
||||||
|
"identity": "issue:r:42:", "elapsed_ms": 0, "running": False,
|
||||||
|
"break_deadline_at": 1_000_000,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def test_break_expiry_never_restarts_time_and_resume_is_explicit_and_idempotent():
|
def test_break_expiry_never_restarts_time_and_resume_is_explicit_and_idempotent():
|
||||||
script = TIMER.read_text() + r"""
|
script = TIMER.read_text() + r"""
|
||||||
const values = new Map();
|
const values = new Map();
|
||||||
|
|
|
||||||
|
|
@ -57,6 +57,56 @@ const sync = createTodaySessionSync({
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_second_device_sees_and_resumes_a_timed_break():
|
||||||
|
result = run_node(
|
||||||
|
r"""
|
||||||
|
const calls=[];
|
||||||
|
const remote={revision:3,device_id:'phone-a',identity:'issue:r:42:',elapsed_ms:90000,running:false,break_deadline_at:1800000,updated_at:10};
|
||||||
|
let adopted=null;
|
||||||
|
const offers=[];
|
||||||
|
const sync=createTodaySessionSync({
|
||||||
|
getDeviceId:()=> 'desktop-b',
|
||||||
|
fetchJson:async (url,options={})=>{
|
||||||
|
calls.push(options.body ? JSON.parse(options.body) : null);
|
||||||
|
return options.method === 'PATCH' ? {...remote,revision:4,device_id:'desktop-b',running:true,break_deadline_at:null} : remote;
|
||||||
|
},
|
||||||
|
timer:{adopt:(identity,elapsed,running)=>{adopted={identity,elapsed,running};}},
|
||||||
|
onRemote:session=>offers.push(session),
|
||||||
|
});
|
||||||
|
(async()=>{
|
||||||
|
await sync.refresh();
|
||||||
|
const claimed=await sync.claim();
|
||||||
|
process.stdout.write(JSON.stringify({offers,claimed,adopted,calls}));
|
||||||
|
})().catch(error=>{console.error(error);process.exit(1);});
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["offers"][0]["break_deadline_at"] == 1_800_000
|
||||||
|
assert result["calls"][1] == {
|
||||||
|
"base_revision": 3, "device_id": "desktop-b", "identity": "issue:r:42:",
|
||||||
|
"elapsed_ms": 90_000, "running": True, "break_deadline_at": None,
|
||||||
|
}
|
||||||
|
assert result["adopted"] == {
|
||||||
|
"identity": "issue:r:42:", "elapsed": 90_000, "running": True,
|
||||||
|
}
|
||||||
|
assert result["claimed"]["device_id"] == "desktop-b"
|
||||||
|
|
||||||
|
|
||||||
|
def test_break_handoff_summary_is_actionable_and_privacy_safe():
|
||||||
|
result = run_node(
|
||||||
|
r"""
|
||||||
|
const session={identity:'issue:private/repo:42:',elapsed_ms:90000,running:false,break_deadline_at:1800000};
|
||||||
|
process.stdout.write(JSON.stringify({
|
||||||
|
summary:todaySessionHandoffSummary(session,{title:'Secret issue'},0),
|
||||||
|
}));
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["summary"].startswith("On break until ")
|
||||||
|
assert "Secret issue" not in result["summary"]
|
||||||
|
assert "private/repo" not in result["summary"]
|
||||||
|
|
||||||
|
|
||||||
def test_previous_owner_pauses_after_another_device_claims():
|
def test_previous_owner_pauses_after_another_device_claims():
|
||||||
result = run_node(
|
result = run_node(
|
||||||
r"""
|
r"""
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ def test_active_session_is_durable_and_account_scoped(tmp_path):
|
||||||
"identity": "issue:stackchain/dashboard:42:",
|
"identity": "issue:stackchain/dashboard:42:",
|
||||||
"elapsed_ms": 90_000,
|
"elapsed_ms": 90_000,
|
||||||
"running": True,
|
"running": True,
|
||||||
|
"break_deadline_at": None,
|
||||||
"updated_at": 1234.5,
|
"updated_at": 1234.5,
|
||||||
}
|
}
|
||||||
assert TodayStore(path).get_session("timmy") == saved
|
assert TodayStore(path).get_session("timmy") == saved
|
||||||
|
|
@ -31,10 +32,26 @@ def test_active_session_is_durable_and_account_scoped(tmp_path):
|
||||||
"identity": "",
|
"identity": "",
|
||||||
"elapsed_ms": 0,
|
"elapsed_ms": 0,
|
||||||
"running": False,
|
"running": False,
|
||||||
|
"break_deadline_at": None,
|
||||||
"updated_at": None,
|
"updated_at": None,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_timed_break_deadline_is_durable_in_the_account_session(tmp_path):
|
||||||
|
path = tmp_path / "today.sqlite3"
|
||||||
|
store = TodayStore(path, clock=lambda: 1234.5)
|
||||||
|
|
||||||
|
saved = store.update_session(
|
||||||
|
"timmy", base_revision=0, device_id="phone-a",
|
||||||
|
identity="issue:r:42:", elapsed_ms=90_000, running=False,
|
||||||
|
break_deadline_at=1_800_000,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert saved["break_deadline_at"] == 1_800_000
|
||||||
|
assert saved["running"] is False
|
||||||
|
assert TodayStore(path).get_session("timmy") == saved
|
||||||
|
|
||||||
|
|
||||||
def test_active_session_claim_rejects_a_stale_revision(tmp_path):
|
def test_active_session_claim_rejects_a_stale_revision(tmp_path):
|
||||||
store = TodayStore(tmp_path / "today.sqlite3")
|
store = TodayStore(tmp_path / "today.sqlite3")
|
||||||
first = store.update_session(
|
first = store.update_session(
|
||||||
|
|
@ -56,6 +73,33 @@ def test_active_session_claim_rejects_a_stale_revision(tmp_path):
|
||||||
assert store.get_session("timmy") == claimed
|
assert store.get_session("timmy") == claimed
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_active_session_api_persists_a_bounded_break_deadline(monkeypatch, tmp_path):
|
||||||
|
async def user():
|
||||||
|
return {"login": "timmy"}
|
||||||
|
|
||||||
|
store = TodayStore(tmp_path / "today.sqlite3")
|
||||||
|
monkeypatch.setattr(main, "current_user", user)
|
||||||
|
monkeypatch.setattr(main, "_today_store", lambda: store)
|
||||||
|
payload = main.TodaySessionUpdate(
|
||||||
|
base_revision=0, device_id="phone-a", identity="issue:r:1:",
|
||||||
|
elapsed_ms=5_000, running=False, break_deadline_at=1_800_000,
|
||||||
|
)
|
||||||
|
|
||||||
|
saved = await main.update_today_session(payload)
|
||||||
|
|
||||||
|
assert saved["break_deadline_at"] == 1_800_000
|
||||||
|
assert (await main.get_today_session()) == saved
|
||||||
|
|
||||||
|
|
||||||
|
def test_active_session_api_rejects_a_running_break():
|
||||||
|
with pytest.raises(ValueError, match="break must remain paused"):
|
||||||
|
main.TodaySessionUpdate(
|
||||||
|
base_revision=0, device_id="phone-a", identity="issue:r:1:",
|
||||||
|
elapsed_ms=5_000, running=True, break_deadline_at=1_800_000,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
async def test_active_session_api_returns_current_state_on_claim_conflict(monkeypatch, tmp_path):
|
async def test_active_session_api_returns_current_state_on_claim_conflict(monkeypatch, tmp_path):
|
||||||
async def user():
|
async def user():
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user