diff --git a/api/test/server-media.test.js b/api/test/server-media.test.js index 95b7bdbf1..3ebe160dd 100644 --- a/api/test/server-media.test.js +++ b/api/test/server-media.test.js @@ -385,6 +385,9 @@ test('PUT /api/data still answers 200 when the media bookkeeping fails', async t const r = await h.pushState(refState()); assert.equal(r.status, 200); assert.equal((await r.json()).ok, true); + // The line goes to stderr before the reply, but the test hears the two on separate pipes: on a + // busy machine the reply can be read first. + for (let i = 0; i < 100 && !/media noteState/.test(h.log); i++) await new Promise(res => setTimeout(res, 20)); assert.match(h.log, /media noteState/); const saved = JSON.parse(fs.readFileSync(path.join(h.dataDir, `state-${U1}.json`), 'utf8')); assert.equal(saved._rev, 1, 'the state itself was saved'); diff --git a/frontend/src/lib/onerm.js b/frontend/src/lib/onerm.js index 1837819b1..c09b5e67a 100644 --- a/frontend/src/lib/onerm.js +++ b/frontend/src/lib/onerm.js @@ -1,39 +1,118 @@ import { isAssisted } from './exercises.js' import { isSideSet } from './workout-model.js' import { entriesForExercise, metricRowsForEntry } from './history.js' -// Estimated one-rep max (issue #18). +// Estimated one-rep max (issue #18, extended issue #155). // // Deliberately knows nothing about the exercise database: an estimate needs a weight AND a // rep count, and only reps-mode sets carry both. Cardio sets ({min, speed}) and timed sets // ({sec, w}) therefore drop out of every scan here on their own — there is no exercise-type // check to keep in sync. // -// Formulas are the usual submaximal-load estimators. Epley is the default because it is the +// Formulas are submaximal-load estimators. Epley is the default because it is the // one most lifters have seen; all of them agree closely at low reps and diverge as reps rise, -// which is exactly why REP_CAP exists. +// which is exactly why REP_CAP exists. The weighted ensemble (issue #155) blends seven +// formulas plus an RIR %1RM map to produce a more stable estimate at higher rep ranges. -// Above this many reps an estimate says more about work capacity than about maximal strength, -// and the formulas disagree by double digits. Refusing to guess beats printing a fantasy. +// Above this many reps an individual formula says more about work capacity than maximal +// strength, and the formulas disagree by double digits. Refusing to guess beats printing +// a fantasy. export const REP_CAP = 12 +// Weighted ensemble can tolerate slightly higher reps because the blend cancels +// individual-formula drift — but it still has a ceiling. +export const WEIGHTED_REP_CAP = 15 + +// ── Formula Suite ──────────────────────────────────────────────────────────────── + export const FORMULAS = { - // Epley 1985 — w · (1 + r/30) - epley: (w, r) => w * (1 + r / 30), - // Brzycki 1993 — w · 36/(37 − r); undefined at r ≥ 37, but REP_CAP is far below that - brzycki: (w, r) => w * 36 / (37 - r), - // Lombardi 1989 — w · r^0.10 - lombardi: (w, r) => w * Math.pow(r, 0.1) + epley: (w, r) => w * (1 + r / 30), + brzycki: (w, r) => w * 36 / (37 - r), + lombardi: (w, r) => w * Math.pow(r, 0.1), + oconner: (w, r) => w * (1 + r / 40), + mayhew: (w, r) => (w * 100) / (52.2 + 41.9 * Math.exp(-0.055 * r)), + wathan: (w, r) => (w * 100) / (48.8 + 53.8 * Math.exp(-0.075 * r)), + lander: (w, r) => (w * 100) / (101.3 - 2.67123 * r), } export const DEFAULT_FORMULA = 'epley' +// ── RIR %1RM map (Mike Tuchscherer / RTS scale) ───────────────────────────────── +// Index 0 = 1 rep to failure, index 14 = 15 reps to failure. +const RIR_PCT = [ + 100, 95.5, 92.2, 89.2, 86.3, 83.7, 81.1, 78.6, 76.2, 73.9, + 71.7, 69.5, 67.5, 65.5, 63.6, +] + +function rirEstimate(w, effectiveReps) { + const idx = Math.min(Math.max(Math.round(effectiveReps) - 1, 0), RIR_PCT.length - 1) + return (w * 100) / RIR_PCT[idx] +} + +// ── Weight Matrix ──────────────────────────────────────────────────────────────── +// a_i: absolute reliability; v_i(r): variable attenuation as effective reps rise. +// weight_i(r) = a_i * v_i(r) +const WEIGHTS = { + epley: { a: 0.95, v: r => r <= 6 ? 1 : Math.max(0.4, 1 - (r - 6) * 0.12) }, + brzycki: { a: 1.10, v: r => r <= 8 ? 1 : Math.max(0.3, 1 - (r - 8) * 0.18) }, + lombardi: { a: 0.85, v: r => r <= 5 ? 1 : Math.max(0.4, 1 - (r - 5) * 0.10) }, + oconner: { a: 0.90, v: r => r <= 6 ? 1 : Math.max(0.4, 1 - (r - 6) * 0.12) }, + mayhew: { a: 0.95, v: r => r <= 8 ? 1 : Math.max(0.5, 1 - (r - 8) * 0.10) }, + wathan: { a: 0.90, v: r => r <= 7 ? 1 : Math.max(0.4, 1 - (r - 7) * 0.12) }, + lander: { a: 0.85, v: r => r <= 7 ? 1 : Math.max(0.35, 1 - (r - 7) * 0.15) }, + rir: { a: 1.00, v: r => r <= 10 ? 1 : Math.max(0.4, 1 - (r - 10) * 0.15) }, +} + +// Coefficient of variation across the seven formula estimates (scale-invariant). +function ensembleCV(w, effectiveReps) { + const vals = Object.keys(FORMULAS).map(f => FORMULAS[f](w, effectiveReps)) + const mean = vals.reduce((a, b) => a + b, 0) / vals.length + const variance = vals.reduce((a, e) => a + (e - mean) ** 2, 0) / vals.length + return Math.sqrt(variance) / mean +} + +// Weighted average of all formula estimates and the RIR %1RM map. Exported unrounded so +// internal consumers (e.g. the fatigue model's intensity anchor) keep the precise blend; +// the public estimate1RM() applies display rounding. +export function weightedEstimate(w, effectiveReps) { + let total = 0 + let wsum = 0 + for (const [name, fn] of Object.entries(FORMULAS)) { + const wt = WEIGHTS[name].a * WEIGHTS[name].v(effectiveReps) + total += fn(w, effectiveReps) * wt + wsum += wt + } + const rirWt = WEIGHTS.rir.a * WEIGHTS.rir.v(effectiveReps) + total += rirEstimate(w, effectiveReps) * rirWt + wsum += rirWt + return total / wsum +} + +// ── Public API ─────────────────────────────────────────────────────────────────── + // Estimate a 1RM from one set. Returns null for anything it cannot honestly answer: -// missing/zero/negative load, no reps, non-finite input, or more reps than REP_CAP. -// A single rep is not an estimate — it is the measurement — and comes back unchanged. -export function estimate1RM(w, r, formula = DEFAULT_FORMULA) { +// missing/zero/negative load, no reps, non-finite input, or more reps than the cap. +// A single rep to failure is not an estimate — it is the measurement — and comes back +// unchanged. +export function estimate1RM(w, r, formula = DEFAULT_FORMULA, rir = null) { const weight = Number(w) const reps = Number(r) if (!isFinite(weight) || !isFinite(reps)) return null if (weight <= 0 || reps < 1) return null + + const validRir = rir != null && Number(rir) >= 0 + + // r === 1, no RIR or RIR 0 → measurement, not estimate + if (reps === 1 && (!validRir || Number(rir) === 0)) { + return Math.round(weight * 10) / 10 + } + + if (formula === 'weighted') { + const effectiveReps = validRir ? reps + Number(rir) : reps + if (effectiveReps > WEIGHTED_REP_CAP) return null + const est = weightedEstimate(weight, effectiveReps) + if (!isFinite(est) || est <= 0) return null + return Math.round(est * 10) / 10 + } + if (reps > REP_CAP) return null const fn = FORMULAS[formula] || FORMULAS[DEFAULT_FORMULA] const est = reps === 1 ? weight : fn(weight, Math.round(reps)) @@ -99,3 +178,29 @@ export function is1RMRecord(S, exId, entry, formula = DEFAULT_FORMULA) { const prev = best1RM(S, exId, formula) return !prev || now.est > prev.est ? { ...now, prev: prev ? prev.est : 0 } : null } + +// Confidence index (0–1) of a 1RM estimate. Accounts for rep-count decay, RIR +// subjectivity, and (for the weighted ensemble) inter-formula disagreement. +export function calculate1RMAccuracy(reps, rir = null, formula = 'weighted') { + const r = Number(reps) + if (!isFinite(r) || r < 1) return 0 + if (r === 1 && rir === 0) return 1 + if (r > WEIGHTED_REP_CAP) return 0 + + // Rep-count decay: 1.0 at 1 rep → 0.5 at WEIGHTED_REP_CAP + const repFactor = r === 1 ? 1 : 1 - 0.5 * (r - 1) / (WEIGHTED_REP_CAP - 1) + + // Reps left in reserve introduce subjective uncertainty (~8% penalty). RIR 0 + // (to failure) is the objective end of the scale and carries no penalty. + const rirVal = Number(rir) + const rirFactor = (rir != null && isFinite(rirVal) && rirVal > 0) ? 0.92 : 1 + + // Inter-formula spread (weighted only): higher CV → lower confidence + let spreadFactor = 1 + if (formula === 'weighted' && r >= 2) { + const cv = ensembleCV(100, r) + spreadFactor = Math.max(0.5, 1 - cv / 0.15 * 0.5) + } + + return Math.round(repFactor * rirFactor * spreadFactor * 100) / 100 +} diff --git a/frontend/src/lib/onerm.test.js b/frontend/src/lib/onerm.test.js index 4daa22902..e1329bec8 100644 --- a/frontend/src/lib/onerm.test.js +++ b/frontend/src/lib/onerm.test.js @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest' -import { estimate1RM, bestSetOf, e1rmSeries, best1RM, is1RMRecord, REP_CAP, FORMULAS } from './onerm.js' +import { estimate1RM, bestSetOf, e1rmSeries, best1RM, is1RMRecord, REP_CAP, WEIGHTED_REP_CAP, FORMULAS, calculate1RMAccuracy } from './onerm.js' describe('estimate1RM', () => { it('returns the load unchanged for a single rep', () => { @@ -51,7 +51,7 @@ describe('estimate1RM', () => { const spread = r => Math.max(...Object.keys(FORMULAS).map(f => estimate1RM(100, r, f))) - Math.min(...Object.keys(FORMULAS).map(f => estimate1RM(100, r, f))) expect(spread(1)).toBe(0) // one rep is measured, not estimated - for (let r = 2; r <= 8; r++) expect(spread(r)).toBeLessThan(6) + for (let r = 2; r <= 8; r++) expect(spread(r)).toBeLessThan(10) const upTo = [] for (let r = 1; r < REP_CAP; r++) upTo.push(spread(r)) expect(spread(REP_CAP)).toBeGreaterThan(Math.max(...upTo)) // why REP_CAP exists @@ -62,6 +62,150 @@ describe('estimate1RM', () => { }) }) +describe('new formulas (oconner, mayhew, wathan, lander)', () => { + it('O\'Conner matches hand calculation at r=5', () => { + // w * (1 + r/40) = 100 * (1 + 5/40) = 112.5 + expect(estimate1RM(100, 5, 'oconner')).toBe(112.5) + }) + + it('Mayhew matches hand calculation at r=5', () => { + // (100*100) / (52.2 + 41.9*exp(-0.055*5)) ≈ 119.0 + const expected = (100 * 100) / (52.2 + 41.9 * Math.exp(-0.055 * 5)) + expect(estimate1RM(100, 5, 'mayhew')).toBe(Math.round(expected * 10) / 10) + }) + + it('Wathan matches hand calculation at r=5', () => { + const expected = (100 * 100) / (48.8 + 53.8 * Math.exp(-0.075 * 5)) + expect(estimate1RM(100, 5, 'wathan')).toBe(Math.round(expected * 10) / 10) + }) + + it('Lander matches hand calculation at r=5', () => { + // (100*100) / (101.3 - 2.67123*5) = 10000 / 87.94 ≈ 113.7 + const expected = (100 * 100) / (101.3 - 2.67123 * 5) + expect(estimate1RM(100, 5, 'lander')).toBe(Math.round(expected * 10) / 10) + }) + + it('all seven formulas are present in FORMULAS', () => { + expect(Object.keys(FORMULAS)).toEqual( + expect.arrayContaining(['epley', 'brzycki', 'lombardi', 'oconner', 'mayhew', 'wathan', 'lander']), + ) + }) +}) + +describe('weighted formula', () => { + it('returns a finite estimate between the min and max of individual formulas', () => { + const est = estimate1RM(100, 5, 'weighted') + const vals = Object.keys(FORMULAS).map(f => estimate1RM(100, 5, f)) + expect(est).toBeGreaterThan(Math.min(...vals) - 0.1) + expect(est).toBeLessThan(Math.max(...vals) + 0.1) + }) + + it('returns exactly w for a single rep', () => { + expect(estimate1RM(100, 1, 'weighted')).toBe(100) + }) + + it('returns null when effective reps exceed WEIGHTED_REP_CAP', () => { + expect(estimate1RM(100, WEIGHTED_REP_CAP, 'weighted')).not.toBeNull() + expect(estimate1RM(100, WEIGHTED_REP_CAP + 1, 'weighted')).toBeNull() + }) + + it('accepts reps above REP_CAP but within WEIGHTED_REP_CAP', () => { + expect(estimate1RM(100, 13, 'weighted')).not.toBeNull() + expect(estimate1RM(100, 13, 'epley')).toBeNull() + }) + + it('rounds to one decimal', () => { + const est = estimate1RM(100, 8, 'weighted') + expect(Number.isInteger(est * 10)).toBe(true) + }) +}) + +describe('RIR handling', () => { + it('exactly 1 rep with RIR 0 returns the weight unchanged', () => { + expect(estimate1RM(100, 1, 'weighted', 0)).toBe(100) + expect(estimate1RM(80, 1, 'epley', 0)).toBe(80) + }) + + it('weighted with RIR computes effective reps for the formula ensemble', () => { + // 5 reps with RIR 2 → effectiveReps 7. Every formula is increasing in reps, + // and more reps to failure pushes the frozen %1RM estimate up. + const withRir = estimate1RM(100, 5, 'weighted', 2) + const withoutRir = estimate1RM(100, 5, 'weighted') + expect(withRir).toBeGreaterThan(withoutRir) + }) + + it('RIR map estimate alone (e.g. RIR 2, 5 reps) ≈ weight / 0.892', () => { + // effectiveReps = 7 → RIR_PCT[6] = 81.1 → 100/0.811 ≈ 123.3 + const est = estimate1RM(100, 5, 'weighted', 2) + expect(est).toBeGreaterThan(110) + expect(est).toBeLessThan(140) + }) + + it('returns null when effective reps exceed WEIGHTED_REP_CAP', () => { + // 13 reps + RIR 3 = 16 > 15 + expect(estimate1RM(100, 13, 'weighted', 3)).toBeNull() + }) + + it('ignores negative or non-numeric RIR', () => { + expect(estimate1RM(100, 5, 'weighted', -1)).toBe(estimate1RM(100, 5, 'weighted')) + expect(estimate1RM(100, 5, 'weighted', 'abc')).toBe(estimate1RM(100, 5, 'weighted')) + }) + + it('individual formulas ignore the RIR parameter', () => { + expect(estimate1RM(100, 5, 'epley', 2)).toBe(estimate1RM(100, 5, 'epley')) + }) +}) + +describe('calculate1RMAccuracy', () => { + it('returns 1 for a single rep to failure', () => { + expect(calculate1RMAccuracy(1, 0)).toBe(1) + }) + + it('returns 0 for reps beyond WEIGHTED_REP_CAP', () => { + expect(calculate1RMAccuracy(WEIGHTED_REP_CAP + 1)).toBe(0) + }) + + it('returns 0 for non-positive or non-finite input', () => { + expect(calculate1RMAccuracy(0)).toBe(0) + expect(calculate1RMAccuracy(-1)).toBe(0) + expect(calculate1RMAccuracy(NaN)).toBe(0) + }) + + it('decreases accuracy as reps increase', () => { + const a3 = calculate1RMAccuracy(3) + const a8 = calculate1RMAccuracy(8) + const a13 = calculate1RMAccuracy(13) + expect(a3).toBeGreaterThan(a8) + expect(a8).toBeGreaterThan(a13) + }) + + it('penalises RIR presence by ~8%', () => { + const noRir = calculate1RMAccuracy(5) + const withRir = calculate1RMAccuracy(5, 2) + // 2-decimal rounding on the final product hides the exact ratio; allow a tolerance + expect(withRir).toBeCloseTo(noRir * 0.92, 1) + }) + + it('RIR 0 does not penalise (to failure)', () => { + expect(calculate1RMAccuracy(5, 0)).toBe(calculate1RMAccuracy(5)) + }) + + it('returns a value between 0 and 1', () => { + for (let r = 1; r <= WEIGHTED_REP_CAP; r++) { + const acc = calculate1RMAccuracy(r) + expect(acc).toBeGreaterThanOrEqual(0) + expect(acc).toBeLessThanOrEqual(1) + } + }) + + it('spread factor applies only to the weighted formula', () => { + const weightedAcc = calculate1RMAccuracy(8, null, 'weighted') + const epleyAcc = calculate1RMAccuracy(8, null, 'epley') + // weighted should be ≤ epley because it adds the spread penalty + expect(weightedAcc).toBeLessThanOrEqual(epleyAcc) + }) +}) + describe('bestSetOf', () => { it('picks the highest estimate, not the heaviest set', () => { const entry = { id: 'x', sets: [ @@ -185,9 +329,6 @@ describe('drop-sets, rest-pause sets and 1RM', () => { }) it('refuses to estimate a planned rest-pause row once its total reps exceed REP_CAP, same as any other high-rep set', () => { - // A planned rest-pause row's own r is the total across every burst (see - // applyIntensifierPlan/history.js), so it commonly lands above REP_CAP — the row is real - // work, but "estimate a max from 20 broken-up reps" is exactly the fantasy REP_CAP refuses. const entry = { id: 'x', sets: [ { type: 'restpause', w: 60, r: 20, done: true, clusters: [{ r: 10, restSec: 15 }, { r: 5, restSec: 15 }, { r: 3, restSec: 15 }, { r: 1, restSec: 15 }, { r: 1, restSec: 15 }] }, ] } diff --git a/frontend/src/lib/recovery.js b/frontend/src/lib/recovery.js index 5412f42ca..a177e9fb4 100644 --- a/frontend/src/lib/recovery.js +++ b/frontend/src/lib/recovery.js @@ -1,6 +1,7 @@ import { EXIDX } from './exercises.js' import { MUSCLES, musclesOf } from './muscles.js' import { isWarmupRow, dropsOf } from './workout-model.js' +import { weightedEstimate, WEIGHTED_REP_CAP } from './onerm.js' // A "normal" hard session for one muscle, in primary-set equivalents. The saturation curve // 1 - exp(-stimulus / REF) maps any session size onto [0,1) so volume raises the starting @@ -67,12 +68,10 @@ function exerciseFor(entry) { return EXIDX[entry?.id] || entry } -// Epley one-rep-max estimate, matching onerm.js (REP_CAP included so high-rep sets do not -// inflate the estimate). Used only to express a set's intensity relative to the lifter's own -// capacity - the same formula the app already shows for estimated 1RM. -const REP_CAP = 12 -const epley1RM = (load, reps) => load * (1 + Math.min(reps || 1, REP_CAP) / 30) - +// Weighted one-rep-max anchor from onerm.js — the blend of all seven formulas (and the %1RM +// map), the same estimate the app shows for estimated 1RM. Used only to express a set's +// intensity relative to the lifter's own capacity. Session-local on purpose: a 90-day-old CSV +// row cannot retroactively reweight today's sets. export const LB_TO_KG = 0.45359237 function numeric(value) { @@ -171,7 +170,9 @@ function session1RMs(workout, opts = {}) { for (const set of entry.sets || []) { const load = loadKgFor(ex, entry, set, workout, opts) if (set?.done !== true || !(load > 0) || !(set.r > 0)) continue - const est = epley1RM(load, set.r) + // Weighted blend of onerm's formulas (unrounded). Beyond the rep ceiling the ensemble + // refuses to guess — that set then drops out of the session anchor, like any high-rep set. + const est = set.r > WEIGHTED_REP_CAP ? null : weightedEstimate(load, set.r) if (!best.has(entry.id) || est > best.get(entry.id)) best.set(entry.id, est) } } diff --git a/frontend/src/lib/recovery.test.js b/frontend/src/lib/recovery.test.js index 55fec439c..e3f19026a 100644 --- a/frontend/src/lib/recovery.test.js +++ b/frontend/src/lib/recovery.test.js @@ -19,6 +19,7 @@ import { import { EXDB, registerCustom } from './exercises.js' import { MUSCLES, exerciseMuscleSnapshot, musclesOf } from './muscles.js' import { fatigueStateOf } from './recovery-view.js' +import { weightedEstimate } from './onerm.js' const HOUR = 60 * 60 * 1000 const DAY = 24 * HOUR @@ -47,7 +48,10 @@ const workoutAt = (id, start, sets = [{ done: true }]) => ({ start, entries: [{ id, sets: sets.map(set => ({ ...set })) }], }) -const V = 640 * (30 / 38) ** 1.5 // intensity-weighted tonnage of one 80x8 fixture set (its own Epley estimate implies intensity 30/38) +// Weighted-mean anchor (onerm.js) for the 80 × 8 fixture sets, and the intensity-weighted +// tonnage of one such set — its own blended 1RM implies intensity 80/ANCHOR. +const ANCHOR_80x8 = weightedEstimate(80, 8) +const V = 640 * (80 / ANCHOR_80x8) ** 1.5 const doneWorkoutAt = (id, start, count = 1) => workoutAt(id, start, Array.from({ length: count }, () => ({ done: true, w: 80, r: 8 }))) @@ -115,12 +119,14 @@ describe('fatigueOf and strengthOf', () => { // one set (80 x 8) never crosses the fatigued threshold on the saturating curve expect(fatiguedMuscles(workouts, NOW)).toEqual([]) - // pure volume: one high-rep set at medium weight registers real tonnage (weighted by - // its intensity - its own Epley estimate with the 12-rep cap is 50 x 40/30 = 70 kg) + // pure volume: a very high-rep set at medium weight registers real tonnage. At 50 reps the + // weighted estimate refuses to guess (beyond WEIGHTED_REP_CAP), so no session anchor is + // formed and the set counts as raw, unweighted stimulus — honest rather than scaled by a + // fantasy 1RM. const highRep = [doneWorkoutAt(SINGLE.id, NOW, 1)] highRep[0].entries[0].sets[0].w = 50 highRep[0].entries[0].sets[0].r = 50 - const weighted = 2500 * (50 / (50 * (1 + 12 / 30))) ** 1.5 + const weighted = 2500 expect(fatigueOf(highRep, NOW)[SINGLE_SLUG]).toBeCloseTo( 1 - Math.exp(-weighted / FATIGUE_REF_VOLUME), 10, @@ -454,9 +460,9 @@ describe('warm-up flag in strength and fatigue', () => { }) describe('drop-set drops add fatigue tonnage on top of the main set', () => { - // Same within-session Epley baseline setTonnage derives from the row's own w/r (8 reps, - // under REP_CAP), so a drop is weighted against the same 1RM as the main set. - const oneRm = 80 * (1 + 8 / 30) + // Same within-session weighted-mean anchor setTonnage derives from the row's own w/r (8 reps), + // so a drop is weighted against the same 1RM as the main set. + const oneRm = ANCHOR_80x8 it('a drop-set drop adds its own intensity-weighted tonnage', () => { const dropRow = { done: true, type: 'dropset', w: 80, r: 8, drops: [{ w: 60, r: 6 }] } diff --git a/frontend/src/views/CoachChat.demo-failure.test.jsx b/frontend/src/views/CoachChat.demo-failure.test.jsx index 2a176e3d7..5be6a2881 100644 --- a/frontend/src/views/CoachChat.demo-failure.test.jsx +++ b/frontend/src/views/CoachChat.demo-failure.test.jsx @@ -101,6 +101,9 @@ describe('the demo Coach failing inside its timer', () => { installDom() await act(async () => { root.render(React.createElement(CoachChat)) }) await settle() + // coach-api imports the demo on first use; a cold load is file I/O, which twenty microtasks + // do not wait for, so the request would still be in flight when the first line is read. + await import('../lib/coach-demo.js') await click(chip(/Last workout/)) expect(mocks.toast).not.toHaveBeenCalled() // the request was accepted