repwise 2.0.0__tar.gz → 2.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. {repwise-2.0.0/src/repwise.egg-info → repwise-2.1.0}/PKG-INFO +1 -1
  2. {repwise-2.0.0 → repwise-2.1.0}/pyproject.toml +1 -1
  3. {repwise-2.0.0 → repwise-2.1.0}/src/repwise/__init__.py +1 -1
  4. {repwise-2.0.0 → repwise-2.1.0}/src/repwise/config.py +71 -15
  5. {repwise-2.0.0 → repwise-2.1.0}/src/repwise/domain/models.py +6 -0
  6. {repwise-2.0.0 → repwise-2.1.0}/src/repwise/domain/progression.py +39 -3
  7. {repwise-2.0.0 → repwise-2.1.0/src/repwise.egg-info}/PKG-INFO +1 -1
  8. {repwise-2.0.0 → repwise-2.1.0}/tests/test_config.py +66 -0
  9. {repwise-2.0.0 → repwise-2.1.0}/tests/test_progression.py +92 -0
  10. {repwise-2.0.0 → repwise-2.1.0}/LICENSE +0 -0
  11. {repwise-2.0.0 → repwise-2.1.0}/README.md +0 -0
  12. {repwise-2.0.0 → repwise-2.1.0}/setup.cfg +0 -0
  13. {repwise-2.0.0 → repwise-2.1.0}/src/repwise/__main__.py +0 -0
  14. {repwise-2.0.0 → repwise-2.1.0}/src/repwise/app/__init__.py +0 -0
  15. {repwise-2.0.0 → repwise-2.1.0}/src/repwise/app/checking.py +0 -0
  16. {repwise-2.0.0 → repwise-2.1.0}/src/repwise/app/fetch.py +0 -0
  17. {repwise-2.0.0 → repwise-2.1.0}/src/repwise/app/importing.py +0 -0
  18. {repwise-2.0.0 → repwise-2.1.0}/src/repwise/app/listing.py +0 -0
  19. {repwise-2.0.0 → repwise-2.1.0}/src/repwise/app/logout.py +0 -0
  20. {repwise-2.0.0 → repwise-2.1.0}/src/repwise/app/report.py +0 -0
  21. {repwise-2.0.0 → repwise-2.1.0}/src/repwise/app/update.py +0 -0
  22. {repwise-2.0.0 → repwise-2.1.0}/src/repwise/checker.py +0 -0
  23. {repwise-2.0.0 → repwise-2.1.0}/src/repwise/cli/__init__.py +0 -0
  24. {repwise-2.0.0 → repwise-2.1.0}/src/repwise/cli/completion.py +0 -0
  25. {repwise-2.0.0 → repwise-2.1.0}/src/repwise/cli/parser.py +0 -0
  26. {repwise-2.0.0 → repwise-2.1.0}/src/repwise/domain/__init__.py +0 -0
  27. {repwise-2.0.0 → repwise-2.1.0}/src/repwise/domain/effort.py +0 -0
  28. {repwise-2.0.0 → repwise-2.1.0}/src/repwise/domain/matching.py +0 -0
  29. {repwise-2.0.0 → repwise-2.1.0}/src/repwise/dumps.py +0 -0
  30. {repwise-2.0.0 → repwise-2.1.0}/src/repwise/errors.py +0 -0
  31. {repwise-2.0.0 → repwise-2.1.0}/src/repwise/garmin/__init__.py +0 -0
  32. {repwise-2.0.0 → repwise-2.1.0}/src/repwise/garmin/catalog.py +0 -0
  33. {repwise-2.0.0 → repwise-2.1.0}/src/repwise/garmin/client.py +0 -0
  34. {repwise-2.0.0 → repwise-2.1.0}/src/repwise/garmin/payloads.py +0 -0
  35. {repwise-2.0.0 → repwise-2.1.0}/src/repwise/importer.py +0 -0
  36. {repwise-2.0.0 → repwise-2.1.0}/src/repwise/log.py +0 -0
  37. {repwise-2.0.0 → repwise-2.1.0}/src/repwise/planner.py +0 -0
  38. {repwise-2.0.0 → repwise-2.1.0}/src/repwise/yamlio.py +0 -0
  39. {repwise-2.0.0 → repwise-2.1.0}/src/repwise.egg-info/SOURCES.txt +0 -0
  40. {repwise-2.0.0 → repwise-2.1.0}/src/repwise.egg-info/dependency_links.txt +0 -0
  41. {repwise-2.0.0 → repwise-2.1.0}/src/repwise.egg-info/entry_points.txt +0 -0
  42. {repwise-2.0.0 → repwise-2.1.0}/src/repwise.egg-info/requires.txt +0 -0
  43. {repwise-2.0.0 → repwise-2.1.0}/src/repwise.egg-info/top_level.txt +0 -0
  44. {repwise-2.0.0 → repwise-2.1.0}/tests/test_catalog.py +0 -0
  45. {repwise-2.0.0 → repwise-2.1.0}/tests/test_checker.py +0 -0
  46. {repwise-2.0.0 → repwise-2.1.0}/tests/test_checking.py +0 -0
  47. {repwise-2.0.0 → repwise-2.1.0}/tests/test_cli.py +0 -0
  48. {repwise-2.0.0 → repwise-2.1.0}/tests/test_client.py +0 -0
  49. {repwise-2.0.0 → repwise-2.1.0}/tests/test_completion.py +0 -0
  50. {repwise-2.0.0 → repwise-2.1.0}/tests/test_dumps.py +0 -0
  51. {repwise-2.0.0 → repwise-2.1.0}/tests/test_effort.py +0 -0
  52. {repwise-2.0.0 → repwise-2.1.0}/tests/test_fetch.py +0 -0
  53. {repwise-2.0.0 → repwise-2.1.0}/tests/test_importer.py +0 -0
  54. {repwise-2.0.0 → repwise-2.1.0}/tests/test_log.py +0 -0
  55. {repwise-2.0.0 → repwise-2.1.0}/tests/test_logout.py +0 -0
  56. {repwise-2.0.0 → repwise-2.1.0}/tests/test_main.py +0 -0
  57. {repwise-2.0.0 → repwise-2.1.0}/tests/test_matching.py +0 -0
  58. {repwise-2.0.0 → repwise-2.1.0}/tests/test_models.py +0 -0
  59. {repwise-2.0.0 → repwise-2.1.0}/tests/test_payloads.py +0 -0
  60. {repwise-2.0.0 → repwise-2.1.0}/tests/test_planner.py +0 -0
  61. {repwise-2.0.0 → repwise-2.1.0}/tests/test_report.py +0 -0
  62. {repwise-2.0.0 → repwise-2.1.0}/tests/test_update.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: repwise
3
- Version: 2.0.0
3
+ Version: 2.1.0
4
4
  Summary: Advance Garmin strength workout targets using double progression
5
5
  Author-email: Adam Żmuda <adam@zmuda.pro>
6
6
  License-Expression: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "repwise"
7
- version = "2.0.0"
7
+ version = "2.1.0"
8
8
  description = "Advance Garmin strength workout targets using double progression"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -1,4 +1,4 @@
1
1
  """Advance Garmin strength workout targets using double progression."""
2
2
 
3
3
  __all__ = ["__version__"]
4
- __version__ = "2.0.0"
4
+ __version__ = "2.1.0"
@@ -50,17 +50,21 @@ _REQUIRED = ("name", "garmin_name", "rep_low", "rep_high", "sets", "load")
50
50
 
51
51
  @dataclass(frozen=True)
52
52
  class LoadRules:
53
- """What a load type does: how big its steps are, and how light it can go.
53
+ """What a load type does: its step size, and how light and heavy it goes.
54
54
 
55
- Both are properties of the equipment rather than of any one exercise - a
56
- dumbbell rack goes up in ones and starts at one - so they are declared once
57
- per load type and only overridden where an exercise really differs.
55
+ All three are properties of the equipment rather than of any one exercise -
56
+ a dumbbell rack goes up in ones, starts at one and ends at whatever the
57
+ heaviest pair is - so they are declared once per load type and only
58
+ overridden where an exercise really differs.
58
59
  """
59
60
 
60
61
  steps: dict[str, float] = field(default_factory=dict)
61
62
  #: Missing entries mean no floor, so a config written before deloads
62
63
  #: existed keeps loading and simply never stops a deload.
63
64
  minimums: dict[str, float] = field(default_factory=dict)
65
+ #: Missing entries mean no ceiling, which is the usual case: a gym's rack
66
+ #: outlasts you, and only equipment you own runs out.
67
+ maximums: dict[str, float] = field(default_factory=dict)
64
68
 
65
69
 
66
70
  def _xdg_config_home() -> str:
@@ -285,6 +289,57 @@ class Problems:
285
289
  raise ConfigError(f"{len(self.found)} problems:\n{listed}")
286
290
 
287
291
 
292
+ def _bounds(
293
+ raw: dict, load: str, loads: LoadRules, where: str, problems: Problems
294
+ ) -> tuple[float, float | None]:
295
+ """How light and how heavy this exercise may be loaded.
296
+
297
+ Resolved together because they only mean anything against each other: a
298
+ ceiling under its own floor leaves progression choosing between two
299
+ impossible loads, and that is only visible once both are known.
300
+
301
+ The two ends default differently, and deliberately. No floor is zero, which
302
+ is the honest answer for anything lifted with no equipment of its own. No
303
+ ceiling is `None` rather than zero, because zero is a real maximum -
304
+ nothing may be added at all - and equipment that does not run out before
305
+ you do is the common case. Either way a config written before deloads or
306
+ ceilings existed keeps its meaning exactly.
307
+
308
+ Anything contradictory is dropped rather than honoured. The file will not
309
+ load either way - `problems` sees to that - so the only question is which
310
+ spec the rest of the checks get to run against.
311
+ """
312
+ declared_minimum = raw.get("min_weight")
313
+ if declared_minimum is None:
314
+ minimum = float(loads.minimums.get(load, 0.0))
315
+ else:
316
+ minimum = float(declared_minimum)
317
+ if minimum < 0:
318
+ problems.add(f"{where}: {raw['name']!r} has a negative min_weight")
319
+ minimum = 0.0
320
+
321
+ declared_maximum = raw.get("max_weight")
322
+ if declared_maximum is None:
323
+ ceiling = loads.maximums.get(load)
324
+ if ceiling is None:
325
+ return minimum, None
326
+ maximum = float(ceiling)
327
+ else:
328
+ maximum = float(declared_maximum)
329
+
330
+ if maximum < 0:
331
+ problems.add(f"{where}: {raw['name']!r} has a negative max_weight")
332
+ return minimum, None
333
+ if maximum < minimum:
334
+ problems.add(
335
+ f"{where}: {raw['name']!r} has a max_weight of {maximum:g} below "
336
+ f"its min_weight of {minimum:g}, so no load fits between them"
337
+ )
338
+ return minimum, None
339
+
340
+ return minimum, maximum
341
+
342
+
288
343
  def _bodyweight_factor(raw: dict, where: str, problems: Problems) -> float:
289
344
  """The share of the lifter this exercise carries, if it says.
290
345
 
@@ -352,17 +407,16 @@ def _build_exercise(
352
407
  problems.add(f"{where}: {raw['name']!r} has a negative start_weight")
353
408
  start_weight = 0.0
354
409
 
355
- # The lightest this can be loaded. Unset is no floor rather than an error:
356
- # a config predating deloads should keep working, and zero is the honest
357
- # answer for anything lifted with no equipment of its own.
358
- declared_minimum = raw.get("min_weight")
359
- if declared_minimum is None:
360
- min_weight = float(loads.minimums.get(load, 0.0))
361
- else:
362
- min_weight = float(declared_minimum)
363
- if min_weight < 0:
364
- problems.add(f"{where}: {raw['name']!r} has a negative min_weight")
365
- min_weight = 0.0
410
+ min_weight, max_weight = _bounds(raw, load, loads, where, problems)
411
+ if max_weight is not None and start_weight > max_weight:
412
+ # `start_weight` is read only when the step is created, which is the
413
+ # one moment nothing else could catch this: no session has been logged
414
+ # yet, so progression never gets a chance to refuse the weight.
415
+ problems.add(
416
+ f"{where}: {raw['name']!r} would start at {start_weight:g} kg, "
417
+ f"above its own max_weight of {max_weight:g}"
418
+ )
419
+ max_weight = None
366
420
 
367
421
  bodyweight_factor = _bodyweight_factor(raw, where, problems)
368
422
 
@@ -382,6 +436,7 @@ def _build_exercise(
382
436
  notes=raw.get("notes"),
383
437
  start_weight=start_weight,
384
438
  min_weight=min_weight,
439
+ max_weight=max_weight,
385
440
  bodyweight_factor=bodyweight_factor,
386
441
  )
387
442
 
@@ -473,6 +528,7 @@ def load_config(path: str | None = None) -> Config:
473
528
  loads = LoadRules(
474
529
  steps=settings.get("weight_steps") or {},
475
530
  minimums=settings.get("min_weights") or {},
531
+ maximums=settings.get("max_weights") or {},
476
532
  )
477
533
  garmin_raw = settings.get("garmin") or {}
478
534
  defaults = GarminSettings()
@@ -38,6 +38,12 @@ class ExerciseSpec:
38
38
  #: the lightest pair of dumbbells, the top plate of the stack. A deload
39
39
  #: stops here rather than prescribing a weight that does not exist.
40
40
  min_weight: float = 0.0
41
+ #: The heaviest this exercise can be loaded: the largest pair of dumbbells
42
+ #: you own, the last plate in the bag, the bottom of the stack. Topping out
43
+ #: the rep range stops here rather than prescribing a weight you cannot
44
+ #: load. `None` is no ceiling, and is the right default - a gym rack runs
45
+ #: out long after you do, and it is home equipment that really ends.
46
+ max_weight: float | None = None
41
47
  #: The share of your bodyweight this movement carries: 1.0 for a calf raise
42
48
  #: or a weighted pull-up, 0 for anything you lie or sit down to do. Never
43
49
  #: guessed from the category - a lat pull-down is categorised `PULL_UP` and
@@ -304,8 +304,39 @@ def _advance(
304
304
  # different weight moves the prescription by more than one step. Name
305
305
  # what was lifted, otherwise the jump looks arbitrary.
306
306
  lifted = f" at {weight:g} kg" if rebased else ""
307
+ heavier = weight + spec.weight_step
308
+ # `max_weight` is the heaviest load that exists to be prescribed, so a
309
+ # step past it is shortened to land *on* it rather than refused. The
310
+ # last pair on the rack is still a pair, and stopping short of a weight
311
+ # you own would leave the top of your equipment permanently unused for
312
+ # the sake of a step size the equipment never promised to divide into.
313
+ #
314
+ # Not the mirror of the deload's floor, deliberately. A short step up
315
+ # is a smaller increase than usual, which is always safe to prescribe;
316
+ # a short step *down* is a smaller decrease than usual, which may not
317
+ # be enough to break the stall that asked for it. So the ceiling is
318
+ # rounded to and the floor is refused.
319
+ if spec.max_weight is not None and heavier > spec.max_weight:
320
+ if weight >= spec.max_weight:
321
+ # Nothing left to add. The target settles at the top of the
322
+ # range, which is where bodyweight work already lives: once
323
+ # the load has run out, the range is all there is to progress.
324
+ # "past" rather than "at" for a load above the ceiling, which
325
+ # is what a target set before the ceiling was declared leaves
326
+ # behind. Rule 3 does not pull it down - taking load off is the
327
+ # deload's job - so the report has to be able to say so.
328
+ sits = "already at" if weight == spec.max_weight else "past"
329
+ return (
330
+ Target(spec.rep_high, weight),
331
+ f"top of the range, {sits} the {spec.max_weight:g} kg maximum",
332
+ )
333
+ return (
334
+ Target(spec.rep_low, spec.max_weight),
335
+ f"hit {floor} on every set{lifted}, top of the range, "
336
+ f"up to the {spec.max_weight:g} kg maximum",
337
+ )
307
338
  return (
308
- Target(spec.rep_low, weight + spec.weight_step),
339
+ Target(spec.rep_low, heavier),
309
340
  f"hit {floor} on every set{lifted}, top of the range",
310
341
  )
311
342
 
@@ -330,6 +361,11 @@ def _advance(
330
361
  if not rebased and floor > current.reps:
331
362
  beat = f"beat target ({floor} on every set), "
332
363
  stalled = f"hit after {streak} miss{'es' if streak > 1 else ''}, " if streak else ""
364
+ # A timed hold is progressed in seconds -- `as_time` put them where the
365
+ # reps go -- so the step has to be named in the unit the exercise is
366
+ # actually measured in. "add 1 rep" beside a 25 s -> 26 s target names
367
+ # something the exercise does not have.
368
+ unit = "second" if spec.time_based else "rep"
333
369
  plural = "" if spec.rep_step == 1 else "s"
334
370
 
335
371
  if lead >= spec.sets:
@@ -338,12 +374,12 @@ def _advance(
338
374
  reps = min(base + spec.rep_step, spec.rep_high)
339
375
  return (
340
376
  Target(reps, weight),
341
- f"{beat}{stalled}add {spec.rep_step} rep{plural}{moved}",
377
+ f"{beat}{stalled}add {spec.rep_step} {unit}{plural}{moved}",
342
378
  )
343
379
 
344
380
  return (
345
381
  Target(base, weight, lead),
346
- f"{beat}{stalled}add {spec.rep_step} rep{plural} on "
382
+ f"{beat}{stalled}add {spec.rep_step} {unit}{plural} on "
347
383
  f"{units} of {spec.sets} sets{moved}",
348
384
  )
349
385
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: repwise
3
- Version: 2.0.0
3
+ Version: 2.1.0
4
4
  Summary: Advance Garmin strength workout targets using double progression
5
5
  Author-email: Adam Żmuda <adam@zmuda.pro>
6
6
  License-Expression: MIT
@@ -698,6 +698,72 @@ def test_a_negative_min_weight_is_rejected(write_config):
698
698
  load_config(write_config(text))
699
699
 
700
700
 
701
+ # --- how heavy a load can go ----------------------------------------------
702
+
703
+
704
+ def test_max_weights_come_from_the_load_type(write_config):
705
+ text = FIXTURE.replace(
706
+ " weight_steps:",
707
+ " max_weights:\n barbell: 100.0\n\n weight_steps:",
708
+ )
709
+ config = load_config(write_config(text))
710
+ assert config["Workout A"].exercises[0].max_weight == 100.0
711
+
712
+
713
+ def test_an_exercise_can_set_its_own_max_weight(write_config):
714
+ text = FIXTURE.replace(
715
+ " load: barbell", " load: barbell\n max_weight: 60.0"
716
+ )
717
+ config = load_config(write_config(text))
718
+ assert config["Workout A"].exercises[0].max_weight == 60.0
719
+
720
+
721
+ def test_an_exercise_max_weight_beats_the_load_type(write_config):
722
+ """Only this one movement is capped, and the rack is not."""
723
+ text = FIXTURE.replace(
724
+ " weight_steps:",
725
+ " max_weights:\n barbell: 100.0\n\n weight_steps:",
726
+ ).replace(
727
+ " load: barbell", " load: barbell\n max_weight: 60.0"
728
+ )
729
+ config = load_config(write_config(text))
730
+ assert config["Workout A"].exercises[0].max_weight == 60.0
731
+
732
+
733
+ def test_no_max_weights_at_all_means_no_ceiling(write_config):
734
+ """Unset is None rather than zero: zero would be a real maximum."""
735
+ config = load_config(write_config(FIXTURE))
736
+ assert config["Workout A"].exercises[0].max_weight is None
737
+
738
+
739
+ def test_a_negative_max_weight_is_rejected(write_config):
740
+ text = FIXTURE.replace(
741
+ " load: barbell", " load: barbell\n max_weight: -5"
742
+ )
743
+ with pytest.raises(ConfigError, match="negative max_weight"):
744
+ load_config(write_config(text))
745
+
746
+
747
+ def test_a_max_weight_below_the_min_weight_is_rejected(write_config):
748
+ """Nothing could be prescribed between them, so neither end is honoured."""
749
+ text = FIXTURE.replace(
750
+ " load: barbell",
751
+ " load: barbell\n min_weight: 20.0\n max_weight: 10.0",
752
+ )
753
+ with pytest.raises(ConfigError, match="below its min_weight"):
754
+ load_config(write_config(text))
755
+
756
+
757
+ def test_a_start_weight_above_the_max_weight_is_rejected(write_config):
758
+ """The one moment no session exists yet to correct it."""
759
+ text = FIXTURE.replace(
760
+ " load: barbell",
761
+ " load: barbell\n start_weight: 30.0\n max_weight: 10.0",
762
+ )
763
+ with pytest.raises(ConfigError, match="above its own max_weight"):
764
+ load_config(write_config(text))
765
+
766
+
701
767
  # --- reading a stored weight as a real load --------------------------------
702
768
 
703
769
 
@@ -433,6 +433,17 @@ def test_a_timed_hold_ramps_in_seconds():
433
433
  assert target.spread(3) == "47+2"
434
434
 
435
435
 
436
+ def test_a_timed_hold_says_what_it_adds_in_seconds():
437
+ """The step is a second, not a rep: a hold has no reps to add one to."""
438
+ _, why = next_target(PLANK, Target(47, 0.0), held(47, 47, 47))
439
+ assert "add 1 second" in why, why
440
+
441
+
442
+ def test_a_timed_hold_names_seconds_on_a_partial_advance():
443
+ _, why = next_target(PLANK, Target(47, 0.0), held(47, 47, 47), streak=1)
444
+ assert "add 1 second on 2 of 3 sets" in why, why
445
+
446
+
436
447
  # --- how a target is written ----------------------------------------------
437
448
 
438
449
 
@@ -645,3 +656,84 @@ def test_a_deload_and_the_climb_back_are_the_same_ladder():
645
656
  BARBELL_SQUAT, eased, [P(9, 20.0), P(9, 20.0), P(8, 20.0)], streak=0
646
657
  )
647
658
  assert back == Target(9, 20.0), "levelled up, flat again"
659
+
660
+
661
+ # --- topping out ----------------------------------------------------------
662
+ #
663
+ # The mirror of the deload's floor. Rule 3 adds a step every time the range is
664
+ # cleared, and equipment you own runs out; without a ceiling the target climbs
665
+ # to a weight no session can be logged against.
666
+
667
+ HOME_PRESS = spec(
668
+ name="Dumbbell Floor Press",
669
+ garmin_name="DUMBBELL_FLOOR_PRESS",
670
+ garmin_category="BENCH_PRESS",
671
+ rep_low=10,
672
+ rep_high=16,
673
+ sets=3,
674
+ load="dumbbell",
675
+ weight_step=2.5,
676
+ min_weight=2.5,
677
+ max_weight=10.0,
678
+ )
679
+
680
+
681
+ def test_the_weight_never_goes_above_the_maximum():
682
+ """12.5 kg dumbbells you do not own is a target no session can answer."""
683
+ target, why = next_target(HOME_PRESS, Target(16, 10.0), [P(16, 10.0)] * 3)
684
+ assert target == Target(16, 10.0), why
685
+ assert "already at the 10 kg maximum" in why
686
+
687
+
688
+ def test_a_capped_exercise_holds_at_the_top_of_the_range():
689
+ """The same ending as bodyweight: the range is all there is left."""
690
+ target, _ = next_target(HOME_PRESS, Target(16, 10.0), [P(18, 10.0)] * 3)
691
+ assert target == Target(HOME_PRESS.rep_high, 10.0)
692
+
693
+
694
+ def test_the_last_step_is_shortened_to_land_on_the_maximum():
695
+ """9 + 2.5 is 11.5, which does not exist - but the 10 kg pair does."""
696
+ target, why = next_target(HOME_PRESS, Target(16, 9.0), [P(16, 9.0)] * 3)
697
+ assert target == Target(HOME_PRESS.rep_low, 10.0), why
698
+ assert "up to the 10 kg maximum" in why
699
+
700
+
701
+ def test_the_shortened_step_is_taken_once_and_then_holds():
702
+ """Reaching the ceiling is a rung to climb, not a place to hover below."""
703
+ stepped, why = next_target(HOME_PRESS, Target(16, 9.0), [P(16, 9.0)] * 3)
704
+ assert stepped == Target(10, 10.0), why
705
+
706
+ held, why = next_target(HOME_PRESS, stepped, [P(16, 10.0)] * 3)
707
+ assert held == Target(HOME_PRESS.rep_high, 10.0), why
708
+
709
+
710
+ def test_a_load_already_past_the_maximum_is_not_pulled_down():
711
+ """Rule 3 is the rule that adds load; taking it off is the deload's job."""
712
+ target, why = next_target(HOME_PRESS, Target(16, 12.5), [P(16, 12.5)] * 3)
713
+ assert target == Target(16, 12.5), why
714
+ assert "past the 10 kg maximum" in why, "past it, rather than at it"
715
+
716
+
717
+ def test_below_the_maximum_the_weight_still_climbs():
718
+ """The ceiling is a stop, not a brake: every step under it is taken."""
719
+ target, why = next_target(HOME_PRESS, Target(16, 7.5), [P(16, 7.5)] * 3)
720
+ assert target == Target(HOME_PRESS.rep_low, 10.0), why
721
+
722
+
723
+ def test_no_maximum_means_the_weight_keeps_climbing():
724
+ """The default, and what every config written before ceilings did."""
725
+ assert SQUAT.max_weight is None
726
+ target, _ = next_target(SQUAT, Target(10, 20.0), [P(10, 20.0)] * 3)
727
+ assert target == Target(SQUAT.rep_low, 25.0)
728
+
729
+
730
+ def test_a_capped_exercise_still_deloads():
731
+ """Running out of weight upwards says nothing about coming back down."""
732
+ target, why = next_target(HOME_PRESS, Target(10, 10.0), [P(8, 10.0)] * 3, streak=1)
733
+ assert target == Target(HOME_PRESS.rep_low, 7.5), why
734
+
735
+
736
+ def test_a_session_at_the_cap_rebases_off_an_impossible_target():
737
+ """The way back from a target already past the ceiling: lift what you have."""
738
+ target, why = next_target(HOME_PRESS, Target(10, 12.5), [P(12, 10.0)] * 3)
739
+ assert target.weight == 10.0, why
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes