@rm0nroe/coach-claw 1.0.6

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 (100) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +311 -0
  3. package/coach/README.md +99 -0
  4. package/coach/bin/aggregate_facets.py +274 -0
  5. package/coach/bin/analyze.py +678 -0
  6. package/coach/bin/bank.py +247 -0
  7. package/coach/bin/banner_themes.py +645 -0
  8. package/coach/bin/coach_paths.py +33 -0
  9. package/coach/bin/coexistence_check.py +129 -0
  10. package/coach/bin/configure.py +245 -0
  11. package/coach/bin/cron_check.py +81 -0
  12. package/coach/bin/default_statusline.py +135 -0
  13. package/coach/bin/doctor.py +663 -0
  14. package/coach/bin/insights-llm.sh +264 -0
  15. package/coach/bin/insights.sh +163 -0
  16. package/coach/bin/insights_window.py +111 -0
  17. package/coach/bin/marker_io.py +154 -0
  18. package/coach/bin/merge.py +671 -0
  19. package/coach/bin/redact.py +86 -0
  20. package/coach/bin/render_env.py +148 -0
  21. package/coach/bin/reward_hints.py +87 -0
  22. package/coach/bin/run-insights.sh +20 -0
  23. package/coach/bin/run_with_lock.py +85 -0
  24. package/coach/bin/scoring.py +260 -0
  25. package/coach/bin/skill_inventory.py +215 -0
  26. package/coach/bin/stats.py +459 -0
  27. package/coach/bin/status.py +293 -0
  28. package/coach/bin/statusline_self_patch.py +205 -0
  29. package/coach/bin/statusline_variants.py +146 -0
  30. package/coach/bin/statusline_wrap.py +244 -0
  31. package/coach/bin/statusline_wrap_action.py +460 -0
  32. package/coach/bin/switch_to_plugin.py +256 -0
  33. package/coach/bin/themes.py +256 -0
  34. package/coach/bin/user_config.py +176 -0
  35. package/coach/bin/xp_accounting.py +98 -0
  36. package/coach/changelog.md +4 -0
  37. package/coach/default-statusline-command.sh +19 -0
  38. package/coach/default-statusline-wrap-command.sh +15 -0
  39. package/coach/profile.yaml +37 -0
  40. package/coach/tests/conftest.py +13 -0
  41. package/coach/tests/test_aggregate_facets.py +379 -0
  42. package/coach/tests/test_analyze_aggregate.py +153 -0
  43. package/coach/tests/test_analyze_redaction.py +105 -0
  44. package/coach/tests/test_analyze_strengths.py +165 -0
  45. package/coach/tests/test_bank_atomic_write.py +61 -0
  46. package/coach/tests/test_bank_concurrency.py +126 -0
  47. package/coach/tests/test_banner_themes.py +981 -0
  48. package/coach/tests/test_celebrate_dedup.py +409 -0
  49. package/coach/tests/test_coach_paths.py +50 -0
  50. package/coach/tests/test_coexistence_check.py +128 -0
  51. package/coach/tests/test_configure.py +258 -0
  52. package/coach/tests/test_cron_check.py +118 -0
  53. package/coach/tests/test_cron_nudge_hook.py +134 -0
  54. package/coach/tests/test_detection_parity.py +105 -0
  55. package/coach/tests/test_doctor.py +595 -0
  56. package/coach/tests/test_hook_bespoke_dispatch.py +288 -0
  57. package/coach/tests/test_hook_module_resolution.py +116 -0
  58. package/coach/tests/test_hook_relevance.py +996 -0
  59. package/coach/tests/test_hook_render_env.py +364 -0
  60. package/coach/tests/test_hook_session_id_guard.py +160 -0
  61. package/coach/tests/test_insights_llm.py +759 -0
  62. package/coach/tests/test_insights_llm_venv_path.py +109 -0
  63. package/coach/tests/test_insights_window.py +237 -0
  64. package/coach/tests/test_install.py +1150 -0
  65. package/coach/tests/test_install_pyyaml_fallback.py +142 -0
  66. package/coach/tests/test_marker_consumption.py +167 -0
  67. package/coach/tests/test_marker_writer_locking.py +305 -0
  68. package/coach/tests/test_merge.py +413 -0
  69. package/coach/tests/test_no_broken_mktemp.py +90 -0
  70. package/coach/tests/test_render_env.py +137 -0
  71. package/coach/tests/test_render_env_glyphs.py +119 -0
  72. package/coach/tests/test_reward_hints.py +59 -0
  73. package/coach/tests/test_scoring.py +147 -0
  74. package/coach/tests/test_session_start_weekly_trigger.py +92 -0
  75. package/coach/tests/test_skill_inventory.py +368 -0
  76. package/coach/tests/test_stats_hybrid.py +142 -0
  77. package/coach/tests/test_status_accounting.py +41 -0
  78. package/coach/tests/test_statusline_failsafe.py +70 -0
  79. package/coach/tests/test_statusline_self_patch.py +261 -0
  80. package/coach/tests/test_statusline_variants.py +110 -0
  81. package/coach/tests/test_statusline_wrap.py +196 -0
  82. package/coach/tests/test_statusline_wrap_action.py +408 -0
  83. package/coach/tests/test_switch_to_plugin.py +360 -0
  84. package/coach/tests/test_themes.py +104 -0
  85. package/coach/tests/test_user_config.py +160 -0
  86. package/coach/tests/test_wrap_announce_hook.py +130 -0
  87. package/coach/tests/test_xp_accounting.py +55 -0
  88. package/hooks/coach-session-start.py +536 -0
  89. package/hooks/coach-user-prompt.py +2288 -0
  90. package/install-launchd.sh +102 -0
  91. package/install.sh +597 -0
  92. package/launchd/com.local.claude-coach.plist.template +34 -0
  93. package/launchd/run-insights.sh +20 -0
  94. package/npm/coach-claw.js +259 -0
  95. package/package.json +52 -0
  96. package/requirements.txt +11 -0
  97. package/settings-snippet.json +31 -0
  98. package/skills/coach/SKILL.md +107 -0
  99. package/skills/coach-insights/SKILL.md +78 -0
  100. package/skills/config/SKILL.md +149 -0
@@ -0,0 +1,981 @@
1
+ """Per-theme bespoke <coach-celebrate> shapes.
2
+
3
+ Pins literal-text contracts for the five bespoke themes (forge, ocean,
4
+ skyrim, military, hacker) and the regression guard for the seven default
5
+ themes (which must produce None and fall through to the default renderer).
6
+
7
+ Verbatim-render contract: every banner string is fully-resolved Python
8
+ text. These tests pin substrings of that text so a refactor that flips
9
+ emoji or swaps vocabulary fails immediately.
10
+ """
11
+ from __future__ import annotations
12
+
13
+ from datetime import datetime, timezone
14
+
15
+ import pytest
16
+
17
+ import banner_themes
18
+ import stats
19
+ from banner_themes import (
20
+ BESPOKE_THEMES,
21
+ render_celebrate_for_theme,
22
+ _render_verb_style,
23
+ _format_window_phrase,
24
+ SPECS,
25
+ )
26
+
27
+
28
+ @pytest.fixture(autouse=True)
29
+ def _hermetic_stats_globals(monkeypatch):
30
+ """Pin stats.LEVELS to the canonical craft ladder for the duration of
31
+ these tests. Mirrors the autouse pattern in test_stats_hybrid.py:16 โ€”
32
+ without this, a user who has run `/config theme <other>` would see
33
+ these tests fail because L9 threshold + L8 name come from the live
34
+ user config, not the hardcoded defaults the locked shapes assume."""
35
+ monkeypatch.setattr(stats, "LEVELS", stats._build_level_ladder())
36
+ monkeypatch.setattr(stats, "ELO_MIN", 1000)
37
+ monkeypatch.setattr(stats, "ELO_MAX", 2800)
38
+
39
+
40
+ # Reference clock for window-phrase tests: 2026-05-07 17:44 UTC.
41
+ NOW = datetime(2026, 5, 7, 17, 44, tzinfo=timezone.utc)
42
+ YESTERDAY = datetime(2026, 5, 6, 19, 0, tzinfo=timezone.utc)
43
+ TWO_DAYS_AGO = datetime(2026, 5, 5, 12, 0, tzinfo=timezone.utc)
44
+
45
+
46
+ # -----------------------------------------------------------------------------
47
+ # Window phrasing โ€” every theme that includes a "since X" header consumes
48
+ # this dict, so the keys + relative-day logic are pinned here.
49
+
50
+ def test_window_phrase_yesterday():
51
+ p = _format_window_phrase(NOW, YESTERDAY)
52
+ assert p["relative"] == "yesterday"
53
+ assert p["iso_date"] == "2026-05-06"
54
+ assert p["iso_datetime"] == "2026-05-06 19:00"
55
+ assert p["now_iso_date"] == "2026-05-07"
56
+ assert p["now_zulu_time"] == "1744Z"
57
+
58
+
59
+ def test_window_phrase_two_days_ago_uses_iso():
60
+ p = _format_window_phrase(NOW, TWO_DAYS_AGO)
61
+ assert p["relative"] == "2026-05-05"
62
+ assert p["iso_date"] == "2026-05-05"
63
+
64
+
65
+ def test_window_phrase_same_day():
66
+ same = NOW.replace(hour=8)
67
+ p = _format_window_phrase(NOW, same)
68
+ assert p["relative"] == "earlier today"
69
+
70
+
71
+ def test_window_phrase_no_oldest():
72
+ p = _format_window_phrase(NOW, None)
73
+ assert p["relative"] == "earlier"
74
+ assert p["iso_date"] == "2026-05-07"
75
+
76
+
77
+ # -----------------------------------------------------------------------------
78
+ # Bespoke / default theme set guards.
79
+
80
+ def test_bespoke_themes_set_is_exactly_five():
81
+ """If you add or remove a bespoke theme, this test fails โ€” forces a
82
+ conscious choice about what shipping a 6th bespoke shape looks like."""
83
+ assert BESPOKE_THEMES == frozenset({
84
+ "forge", "ocean", "skyrim", "military", "hacker",
85
+ })
86
+
87
+
88
+ @pytest.mark.parametrize("theme", [
89
+ "craft", "cosmic", "marvel", "dc", "finalfantasy", "lotr", "starwars",
90
+ ])
91
+ def test_default_themes_return_none(theme):
92
+ """The seven default themes must defer to the hook's existing renderer.
93
+ None signals 'I'm not handling this โ€” fall through.'"""
94
+ out = render_celebrate_for_theme(
95
+ theme,
96
+ streak_rewards=[{
97
+ "id": "x", "name": "x", "streak": 3, "target": 5,
98
+ "xp_awarded": 1, "direction": "negative",
99
+ }],
100
+ levelup=None,
101
+ now=NOW,
102
+ streak_oldest=YESTERDAY,
103
+ )
104
+ assert out is None
105
+
106
+
107
+ # -----------------------------------------------------------------------------
108
+ # Ocean theme โ€” first verb-style implementation. Pins:
109
+ # header glyph + label + window phrasing
110
+ # row meter + name + verb + arrow+xp shape
111
+ # level-up footer with theme-aware level name + next_xp threshold
112
+
113
+ def _ocean_streak_fixture():
114
+ return [
115
+ {"id": "safe-git-hygiene", "name": "safe git hygiene",
116
+ "streak": 4, "target": 5, "xp_awarded": 2, "direction": "positive"},
117
+ {"id": "effective-skill-use", "name": "effective skill use",
118
+ "streak": 3, "target": 5, "xp_awarded": 1, "direction": "positive"},
119
+ {"id": "good-debugging", "name": "good debugging",
120
+ "streak": 2, "target": 5, "xp_awarded": 1, "direction": "positive"},
121
+ {"id": "heavy-subagent-delegation", "name": "heavy subagent delegation",
122
+ "streak": 4, "target": 5, "xp_awarded": 2, "direction": "negative"},
123
+ {"id": "commit-without-testing", "name": "commit without testing",
124
+ "streak": 3, "target": 5, "xp_awarded": 1, "direction": "negative"},
125
+ ]
126
+
127
+
128
+ def test_ocean_header_uses_lobster_and_relative_phrase():
129
+ out = render_celebrate_for_theme(
130
+ "ocean",
131
+ streak_rewards=_ocean_streak_fixture(),
132
+ levelup=None,
133
+ now=NOW,
134
+ streak_oldest=YESTERDAY,
135
+ )
136
+ assert out is not None
137
+ assert "> ๐Ÿฆž Tide turned ยท since yesterday" in out
138
+
139
+
140
+ def test_ocean_streak_row_positive_direction():
141
+ out = render_celebrate_for_theme(
142
+ "ocean",
143
+ streak_rewards=_ocean_streak_fixture(),
144
+ levelup=None,
145
+ now=NOW,
146
+ streak_oldest=YESTERDAY,
147
+ )
148
+ # Positive-direction row: rising tide + โ†‘arrow.
149
+ assert "โ‰‹โ‰‹โ‰‹โ‰‹ยท safe git hygiene" in out
150
+ assert "rising tide" in out
151
+ assert "โ†‘2" in out
152
+
153
+
154
+ def test_ocean_streak_row_negative_direction():
155
+ out = render_celebrate_for_theme(
156
+ "ocean",
157
+ streak_rewards=_ocean_streak_fixture(),
158
+ levelup=None,
159
+ now=NOW,
160
+ streak_oldest=YESTERDAY,
161
+ )
162
+ # Negative-direction row: ebbing + โ†“arrow.
163
+ assert "heavy subagent delegation" in out
164
+ assert "ebbing" in out
165
+ assert "โ†“2" in out
166
+
167
+
168
+ def test_ocean_meter_glyphs_match_locked_shape():
169
+ """Meter uses โ‰‹ filled and ยท empty per the locked design."""
170
+ out = render_celebrate_for_theme(
171
+ "ocean",
172
+ streak_rewards=[{
173
+ "id": "x", "name": "x", "streak": 4, "target": 5,
174
+ "xp_awarded": 2, "direction": "positive",
175
+ }],
176
+ levelup=None,
177
+ now=NOW,
178
+ streak_oldest=YESTERDAY,
179
+ )
180
+ assert "โ‰‹โ‰‹โ‰‹โ‰‹ยท" in out
181
+ # Default-theme meter must NOT leak in.
182
+ assert "โ—" not in out
183
+ assert "โ–ฐ" not in out
184
+
185
+
186
+ def test_ocean_levelup_footer_uses_theme_level_name_and_next_xp():
187
+ """Level-up at L8 should: (a) pull L8 name from active LEVELS ladder
188
+ (Sensei in the canonical craft baseline), (b) use the L9 threshold
189
+ (125) for `next fathom at X XP` โ€” NOT the just-crossed L8 threshold."""
190
+ out = render_celebrate_for_theme(
191
+ "ocean",
192
+ streak_rewards=[],
193
+ levelup={"to": "Sensei", "to_idx": 7, "xp_at_levelup": 90},
194
+ now=NOW,
195
+ streak_oldest=None,
196
+ )
197
+ assert out is not None
198
+ assert "๐ŸŒŠDeep Water๐ŸŒŠ" in out
199
+ assert "Sensei (L8)" in out
200
+ assert "next fathom at 125 XP" in out
201
+ # Glyph at the front of the footer.
202
+ assert "> โš“ ๐ŸŒŠDeep Water๐ŸŒŠ" in out
203
+
204
+
205
+ def test_ocean_full_block_streak_plus_levelup():
206
+ """Composed: header, blank, rows, blank, levelup. All in one block."""
207
+ out = render_celebrate_for_theme(
208
+ "ocean",
209
+ streak_rewards=_ocean_streak_fixture(),
210
+ levelup={"to": "Sensei", "to_idx": 7, "xp_at_levelup": 90},
211
+ now=NOW,
212
+ streak_oldest=YESTERDAY,
213
+ )
214
+ assert out is not None
215
+ assert out.startswith("<coach-celebrate>")
216
+ assert out.endswith("</coach-celebrate>")
217
+ # Header before rows.
218
+ header_pos = out.index("> ๐Ÿฆž Tide turned")
219
+ row_pos = out.index("safe git hygiene")
220
+ levelup_pos = out.index("๐ŸŒŠDeep Water๐ŸŒŠ")
221
+ assert header_pos < row_pos < levelup_pos
222
+
223
+
224
+ def test_ocean_returns_none_when_nothing_to_render():
225
+ """No streak rewards, no levelup, no grads, no regs โ†’ None.
226
+ Caller must not emit an empty <coach-celebrate>."""
227
+ out = render_celebrate_for_theme(
228
+ "ocean",
229
+ streak_rewards=[],
230
+ levelup=None,
231
+ now=NOW,
232
+ streak_oldest=None,
233
+ )
234
+ assert out is None
235
+
236
+
237
+ def test_ocean_grads_render_between_streak_and_levelup():
238
+ """Pre-rendered graduation block lands BETWEEN the bespoke streak
239
+ section and the bespoke levelup footer โ€” the order locked in the plan."""
240
+ grads_default = "> ๐ŸŽ“โšก๏ธ **GRADUATED: skipped search tools** `+5 XP`\n> `๐Ÿ”ด๐Ÿ”ด๐Ÿ”ด๐Ÿ”ด๐Ÿ”ด` โ€” 5 clean Coach insights runs in a row โ€” weakness retired."
241
+ out = render_celebrate_for_theme(
242
+ "ocean",
243
+ streak_rewards=_ocean_streak_fixture(),
244
+ levelup={"to": "Sensei", "to_idx": 7, "xp_at_levelup": 90},
245
+ grads_block=grads_default,
246
+ now=NOW,
247
+ streak_oldest=YESTERDAY,
248
+ )
249
+ assert out is not None
250
+ streak_pos = out.index("safe git hygiene")
251
+ grad_pos = out.index("GRADUATED: skipped search tools")
252
+ levelup_pos = out.index("๐ŸŒŠDeep Water๐ŸŒŠ")
253
+ assert streak_pos < grad_pos < levelup_pos
254
+
255
+
256
+ def test_ocean_slug_does_not_leak():
257
+ """Slugs (kebab-case ids) must never appear in the rendered banner โ€”
258
+ only the human-readable name."""
259
+ out = render_celebrate_for_theme(
260
+ "ocean",
261
+ streak_rewards=_ocean_streak_fixture(),
262
+ levelup=None,
263
+ now=NOW,
264
+ streak_oldest=YESTERDAY,
265
+ )
266
+ assert "heavy-subagent-delegation" not in out
267
+ assert "safe-git-hygiene" not in out
268
+
269
+
270
+ def test_ocean_levelup_only_no_streak_section_emitted():
271
+ """If only a levelup is queued, header + rows are skipped โ€” output
272
+ is just the level-up footer (no orphan 'Tide turned' header)."""
273
+ out = render_celebrate_for_theme(
274
+ "ocean",
275
+ streak_rewards=[],
276
+ levelup={"to": "Reefer", "to_idx": 7, "xp_at_levelup": 90},
277
+ now=NOW,
278
+ streak_oldest=None,
279
+ )
280
+ assert out is not None
281
+ assert "Tide turned" not in out
282
+ assert "๐ŸŒŠDeep Water๐ŸŒŠ" in out
283
+
284
+
285
+ # -----------------------------------------------------------------------------
286
+ # Forge theme โ€” second verb-style implementation. Validates SPECS scales.
287
+ # header: โš’ The Anvil ยท {oldest} โ†’ now
288
+ # verbs: tempering / quenching
289
+ # meter: โ–ฐโ–ฑ
290
+ # footer: โœจ **{name}** (L{n}) forged anew ยท next heat at X XP
291
+
292
+ def _forge_streak_fixture():
293
+ return [
294
+ {"id": "safe-git-hygiene", "name": "safe git hygiene",
295
+ "streak": 4, "target": 5, "xp_awarded": 2, "direction": "positive"},
296
+ {"id": "heavy-subagent-delegation", "name": "heavy subagent delegation",
297
+ "streak": 4, "target": 5, "xp_awarded": 2, "direction": "negative"},
298
+ ]
299
+
300
+
301
+ def test_forge_header_uses_anvil_glyph_and_iso_window():
302
+ """Forge header uses ISO-date arrow window (not 'since X')."""
303
+ out = render_celebrate_for_theme(
304
+ "forge",
305
+ streak_rewards=_forge_streak_fixture(),
306
+ levelup=None,
307
+ now=NOW,
308
+ streak_oldest=YESTERDAY,
309
+ )
310
+ assert out is not None
311
+ assert "> โš’ The Anvil ยท 2026-05-06 โ†’ now" in out
312
+
313
+
314
+ def test_forge_streak_row_verbs_and_meter():
315
+ out = render_celebrate_for_theme(
316
+ "forge",
317
+ streak_rewards=_forge_streak_fixture(),
318
+ levelup=None,
319
+ now=NOW,
320
+ streak_oldest=YESTERDAY,
321
+ )
322
+ # Positive direction โ†’ tempering verb + โ†‘arrow.
323
+ assert "โ–ฐโ–ฐโ–ฐโ–ฐโ–ฑ" in out
324
+ assert "tempering" in out
325
+ assert "โ†‘2" in out
326
+ # Negative direction โ†’ quenching verb + โ†“arrow.
327
+ assert "quenching" in out
328
+ assert "โ†“2" in out
329
+ # Ocean glyphs must NOT leak into forge.
330
+ assert "โ‰‹" not in out
331
+ assert "rising tide" not in out
332
+
333
+
334
+ def test_forge_levelup_uses_theme_level_name_and_l9_threshold():
335
+ """L8 levelup โ†’ forge ladder name (Mastersmith), L9 threshold (125 XP).
336
+ The user's mockup wrote '90 XP' but that's the L8 threshold; the
337
+ correct render is the threshold the user is heading toward (L9)."""
338
+ out = render_celebrate_for_theme(
339
+ "forge",
340
+ streak_rewards=[],
341
+ levelup={"to": "Mastersmith", "to_idx": 7, "xp_at_levelup": 90},
342
+ now=NOW,
343
+ streak_oldest=None,
344
+ )
345
+ assert out is not None
346
+ assert "โœจ" in out
347
+ assert "**Mastersmith** (L8) forged anew" in out
348
+ assert "next heat at 125 XP" in out
349
+
350
+
351
+ def test_forge_full_block_streak_plus_levelup_ordering():
352
+ out = render_celebrate_for_theme(
353
+ "forge",
354
+ streak_rewards=_forge_streak_fixture(),
355
+ levelup={"to": "Mastersmith", "to_idx": 7, "xp_at_levelup": 90},
356
+ now=NOW,
357
+ streak_oldest=YESTERDAY,
358
+ )
359
+ assert out is not None
360
+ header_pos = out.index("โš’ The Anvil")
361
+ row_pos = out.index("โ–ฐโ–ฐโ–ฐโ–ฐโ–ฑ safe git hygiene")
362
+ levelup_pos = out.index("forged anew")
363
+ assert header_pos < row_pos < levelup_pos
364
+
365
+
366
+ def test_forge_does_not_use_ocean_footer_glyph():
367
+ """Each theme has its own levelup glyph โ€” guards against accidental
368
+ SPEC merge regressions."""
369
+ out = render_celebrate_for_theme(
370
+ "forge",
371
+ streak_rewards=[],
372
+ levelup={"to": "Mastersmith", "to_idx": 7, "xp_at_levelup": 90},
373
+ now=NOW,
374
+ streak_oldest=None,
375
+ )
376
+ # Forge uses โœจ; ocean uses โš“ + ๐ŸŒŠDeep Water๐ŸŒŠ.
377
+ assert "๐ŸŒŠ" not in out
378
+ assert "Deep Water" not in out
379
+ assert "Tide turned" not in out
380
+
381
+
382
+ # -----------------------------------------------------------------------------
383
+ # Skyrim theme โ€” third verb-style implementation. Adds glyph fallback:
384
+ # header + meter use โš” (U+2694) when terminal supports it, โœ• otherwise.
385
+ # header: โš” Saga ยท since {iso_date}
386
+ # verbs: oath kept / curse fades
387
+ # meter: โš”ยท (or โœ•ยท in fallback)
388
+ # footer: โšœ **{name}** (L{n}) โ€” next title at X XP
389
+
390
+ def _skyrim_streak_fixture():
391
+ return [
392
+ {"id": "safe-git-hygiene", "name": "safe git hygiene",
393
+ "streak": 4, "target": 5, "xp_awarded": 2, "direction": "positive"},
394
+ {"id": "heavy-subagent-delegation", "name": "heavy subagent delegation",
395
+ "streak": 4, "target": 5, "xp_awarded": 2, "direction": "negative"},
396
+ ]
397
+
398
+
399
+ def test_skyrim_header_uses_dual_blade_when_supported():
400
+ out = render_celebrate_for_theme(
401
+ "skyrim",
402
+ streak_rewards=_skyrim_streak_fixture(),
403
+ levelup=None,
404
+ now=NOW,
405
+ streak_oldest=YESTERDAY,
406
+ dual_blade_supported=True,
407
+ )
408
+ assert out is not None
409
+ assert "> โš” Saga ยท since 2026-05-06" in out
410
+ # Meter rows use โš” filled.
411
+ assert "โš”โš”โš”โš”ยท" in out
412
+
413
+
414
+ def test_skyrim_falls_back_to_x_glyph_when_dual_blade_unsupported():
415
+ """When supports_dual_blade() returns False, both header glyph AND
416
+ meter glyph swap to โœ• โ€” the fallback is global to the theme."""
417
+ out = render_celebrate_for_theme(
418
+ "skyrim",
419
+ streak_rewards=_skyrim_streak_fixture(),
420
+ levelup=None,
421
+ now=NOW,
422
+ streak_oldest=YESTERDAY,
423
+ dual_blade_supported=False,
424
+ )
425
+ assert out is not None
426
+ assert "> โœ• Saga ยท since 2026-05-06" in out
427
+ assert "โœ•โœ•โœ•โœ•ยท" in out
428
+ # โš” must NOT appear anywhere when fallback is active.
429
+ assert "โš”" not in out
430
+
431
+
432
+ def test_skyrim_streak_row_verbs():
433
+ out = render_celebrate_for_theme(
434
+ "skyrim",
435
+ streak_rewards=_skyrim_streak_fixture(),
436
+ levelup=None,
437
+ now=NOW,
438
+ streak_oldest=YESTERDAY,
439
+ dual_blade_supported=True,
440
+ )
441
+ assert "oath kept" in out
442
+ assert "curse fades" in out
443
+ assert "โ†‘2" in out
444
+ assert "โ†“2" in out
445
+
446
+
447
+ def test_skyrim_levelup_uses_fleur_glyph_and_theme_name():
448
+ """Levelup glyph is โšœ (fleur-de-lis) โ€” distinct from the meter โš”.
449
+ L8 in skyrim ladder is 'Pupil'."""
450
+ out = render_celebrate_for_theme(
451
+ "skyrim",
452
+ streak_rewards=[],
453
+ levelup={"to": "Pupil", "to_idx": 7, "xp_at_levelup": 90},
454
+ now=NOW,
455
+ streak_oldest=None,
456
+ dual_blade_supported=True,
457
+ )
458
+ assert out is not None
459
+ assert "โšœ" in out
460
+ assert "**Pupil** (L8) โ€” next title at 125 XP" in out
461
+
462
+
463
+ def test_skyrim_fallback_does_not_swap_levelup_glyph():
464
+ """โšœ (fleur-de-lis) is single-cell on every modern terminal โ€” only
465
+ โš” swaps to โœ•. Levelup line uses โšœ regardless of fallback state."""
466
+ out = render_celebrate_for_theme(
467
+ "skyrim",
468
+ streak_rewards=[],
469
+ levelup={"to": "Pupil", "to_idx": 7, "xp_at_levelup": 90},
470
+ now=NOW,
471
+ streak_oldest=None,
472
+ dual_blade_supported=False,
473
+ )
474
+ assert out is not None
475
+ assert "โšœ" in out
476
+ # Levelup-only branch emits no header glyph + no meter, so โš”/โœ• shouldn't
477
+ # appear at all here.
478
+ assert "โš”" not in out
479
+ assert "โœ•" not in out
480
+
481
+
482
+ # -----------------------------------------------------------------------------
483
+ # Sort + truncate โ€” pinned independently of any one theme. Group by direction
484
+ # (positive first, then negative), sort each group by streak desc.
485
+
486
+ def test_sort_and_truncate_order():
487
+ """Positive group sorted by streak desc, then negative group sorted
488
+ by streak desc. Determinism: name asc breaks streak ties."""
489
+ rewards = [
490
+ {"id": "a", "name": "a-late", "streak": 1, "target": 5, "xp_awarded": 1, "direction": "negative"},
491
+ {"id": "b", "name": "b-strong", "streak": 4, "target": 5, "xp_awarded": 2, "direction": "positive"},
492
+ {"id": "c", "name": "c-tied", "streak": 2, "target": 5, "xp_awarded": 1, "direction": "positive"},
493
+ {"id": "d", "name": "d-tied", "streak": 2, "target": 5, "xp_awarded": 1, "direction": "positive"},
494
+ {"id": "e", "name": "e-deep", "streak": 4, "target": 5, "xp_awarded": 2, "direction": "negative"},
495
+ ]
496
+ ordered, hidden = banner_themes._sort_and_truncate(rewards)
497
+ assert hidden == 0
498
+ assert [r["name"] for r in ordered] == [
499
+ "b-strong", # positive 4
500
+ "c-tied", # positive 2 (tied, alphabetical)
501
+ "d-tied", # positive 2
502
+ "e-deep", # negative 4
503
+ "a-late", # negative 1
504
+ ]
505
+
506
+
507
+ def test_sort_and_truncate_caps_at_five():
508
+ rewards = [
509
+ {"id": str(i), "name": f"item-{i:02d}", "streak": (i % 5) + 1,
510
+ "target": 5, "xp_awarded": 1, "direction": "positive"}
511
+ for i in range(9)
512
+ ]
513
+ ordered, hidden = banner_themes._sort_and_truncate(rewards)
514
+ assert len(ordered) == 5
515
+ assert hidden == 4
516
+
517
+
518
+ def test_truncation_emits_more_tail_for_verb_style():
519
+ """Forge with 9 rows โ†’ 5 shown + 'โ€ฆ4 more' tail."""
520
+ rewards = [
521
+ {"id": str(i), "name": f"pattern-{i:02d}", "streak": 4,
522
+ "target": 5, "xp_awarded": 2, "direction": "positive"}
523
+ for i in range(9)
524
+ ]
525
+ out = render_celebrate_for_theme(
526
+ "forge",
527
+ streak_rewards=rewards,
528
+ levelup=None,
529
+ now=NOW,
530
+ streak_oldest=YESTERDAY,
531
+ )
532
+ assert out is not None
533
+ assert "โ€ฆ4 more" in out
534
+
535
+
536
+ # -----------------------------------------------------------------------------
537
+ # Hacker theme โ€” divergent shape (no verb column, snake_case names, log
538
+ # frame). Pins the bespoke header, row format, and uplink/breach footer.
539
+
540
+ def _hacker_streak_fixture():
541
+ return [
542
+ {"id": "safe-git-hygiene", "name": "safe git hygiene",
543
+ "streak": 4, "target": 5, "xp_awarded": 2, "direction": "positive"},
544
+ {"id": "good-debugging", "name": "good debugging",
545
+ "streak": 2, "target": 5, "xp_awarded": 1, "direction": "positive"},
546
+ {"id": "heavy-subagent-delegation", "name": "heavy subagent delegation",
547
+ "streak": 4, "target": 5, "xp_awarded": 2, "direction": "negative"},
548
+ ]
549
+
550
+
551
+ def test_hacker_header_uses_shell_prompt_and_dashed_timestamp():
552
+ out = render_celebrate_for_theme(
553
+ "hacker",
554
+ streak_rewards=_hacker_streak_fixture(),
555
+ levelup=None,
556
+ now=NOW,
557
+ streak_oldest=YESTERDAY,
558
+ )
559
+ assert out is not None
560
+ assert "> ๐Ÿ‘พ [coach@claw ~]$ tail -f session.log" in out
561
+ assert "> โ”€โ”€ 2026-05-06 19:00 โ†’ now" in out
562
+ # Trailing dashes after the arrow.
563
+ assert "โ†’ now โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€" in out
564
+
565
+
566
+ def test_hacker_rows_use_snake_case_names_and_bracketed_xp():
567
+ out = render_celebrate_for_theme(
568
+ "hacker",
569
+ streak_rewards=_hacker_streak_fixture(),
570
+ levelup=None,
571
+ now=NOW,
572
+ streak_oldest=YESTERDAY,
573
+ )
574
+ # snake_case: "safe git hygiene" โ†’ "safe_git_hygiene".
575
+ assert "safe_git_hygiene" in out
576
+ assert "good_debugging" in out
577
+ assert "heavy_subagent_delegation" in out
578
+ # XP format: [โ†‘N xp] (lowercase, brackets, โ†‘ for both directions โ€”
579
+ # both kinds of pattern earn XP, the arrow denotes direction-of-XP-
580
+ # movement, not direction-of-pattern).
581
+ assert "[โ†‘2 xp]" in out
582
+ assert "[โ†‘1 xp]" in out
583
+ # Old broken format must NOT leak back.
584
+ assert "[+" not in out
585
+ # Direction is encoded by RUN/KILL row prefix.
586
+ assert "RUN safe_git_hygiene" in out
587
+ assert "RUN good_debugging" in out
588
+ assert "KILL heavy_subagent_delegation" in out
589
+ # Verb-style markers must NOT leak into hacker.
590
+ assert "tempering" not in out
591
+ assert "rising tide" not in out
592
+
593
+
594
+ def test_hacker_negative_direction_uses_kill_prefix():
595
+ """Explicit negative-direction fixture (teammate-flagged P2). A
596
+ weakness retiring renders with KILL prefix and [โ†‘N xp] gain โ€” the
597
+ user earned XP for retiring the weakness, but the row name reads
598
+ as the action they took rather than the bad behavior in isolation."""
599
+ out = render_celebrate_for_theme(
600
+ "hacker",
601
+ streak_rewards=[{
602
+ "id": "heavy-subagent-delegation",
603
+ "name": "heavy subagent delegation",
604
+ "streak": 4, "target": 5, "xp_awarded": 2,
605
+ "direction": "negative",
606
+ }],
607
+ levelup=None,
608
+ now=NOW,
609
+ streak_oldest=YESTERDAY,
610
+ )
611
+ assert out is not None
612
+ assert "KILL heavy_subagent_delegation" in out
613
+ assert "[โ†‘2 xp]" in out
614
+ # Old broken format must not regress.
615
+ assert "[+2 xp]" not in out
616
+ assert "[-2 xp]" not in out
617
+ # Positive prefix must not leak onto a negative row.
618
+ assert "RUN heavy_subagent_delegation" not in out
619
+
620
+
621
+ def test_hacker_positive_direction_uses_run_prefix():
622
+ """Symmetric pin: a strength reinforcing renders with RUN prefix."""
623
+ out = render_celebrate_for_theme(
624
+ "hacker",
625
+ streak_rewards=[{
626
+ "id": "safe-git-hygiene",
627
+ "name": "safe git hygiene",
628
+ "streak": 4, "target": 5, "xp_awarded": 2,
629
+ "direction": "positive",
630
+ }],
631
+ levelup=None,
632
+ now=NOW,
633
+ streak_oldest=YESTERDAY,
634
+ )
635
+ assert out is not None
636
+ assert "RUN safe_git_hygiene" in out
637
+ assert "[โ†‘2 xp]" in out
638
+ # KILL prefix must not leak onto a positive row.
639
+ assert "KILL safe_git_hygiene" not in out
640
+
641
+
642
+ def test_hacker_meter_uses_block_glyphs():
643
+ out = render_celebrate_for_theme(
644
+ "hacker",
645
+ streak_rewards=_hacker_streak_fixture(),
646
+ levelup=None,
647
+ now=NOW,
648
+ streak_oldest=YESTERDAY,
649
+ )
650
+ assert "โ–“โ–“โ–“โ–“โ–‘" in out
651
+ assert "โ–“โ–“โ–‘โ–‘โ–‘" in out
652
+ # Other themes' meters must NOT appear.
653
+ assert "โ‰‹" not in out
654
+ assert "โ–ฐ" not in out
655
+ assert "โš”" not in out
656
+
657
+
658
+ def test_hacker_truncation_uses_ascii_dots_and_status_hint():
659
+ rewards = [
660
+ {"id": str(i), "name": f"pattern-{i:02d}", "streak": 4,
661
+ "target": 5, "xp_awarded": 2, "direction": "positive"}
662
+ for i in range(9)
663
+ ]
664
+ out = render_celebrate_for_theme(
665
+ "hacker",
666
+ streak_rewards=rewards,
667
+ levelup=None,
668
+ now=NOW,
669
+ streak_oldest=YESTERDAY,
670
+ )
671
+ assert out is not None
672
+ # Hacker uses ASCII `...` (3 dots), not `โ€ฆ` ellipsis.
673
+ assert "...4 more" in out
674
+ assert "(cat /coach/status)" in out
675
+ # Ellipsis from verb-style themes must NOT appear.
676
+ assert "โ€ฆ" not in out
677
+
678
+
679
+ def test_hacker_levelup_uses_uplink_and_breach_lines():
680
+ out = render_celebrate_for_theme(
681
+ "hacker",
682
+ streak_rewards=[],
683
+ levelup={"to": "Hacker", "to_idx": 7, "xp_at_levelup": 90},
684
+ now=NOW,
685
+ streak_oldest=None,
686
+ )
687
+ assert out is not None
688
+ # L8 in hacker theme ladder is "Hacker"; next threshold is 125.
689
+ assert "> :: ๐Ÿ“ก UPLINK โ†‘ L8 / Hacker ๐Ÿฅท ::" in out
690
+ assert "> next breach ๐Ÿ”“ 125 xp" in out
691
+
692
+
693
+ def test_hacker_full_block_streak_plus_levelup():
694
+ out = render_celebrate_for_theme(
695
+ "hacker",
696
+ streak_rewards=_hacker_streak_fixture(),
697
+ levelup={"to": "Hacker", "to_idx": 7, "xp_at_levelup": 90},
698
+ now=NOW,
699
+ streak_oldest=YESTERDAY,
700
+ )
701
+ assert out is not None
702
+ header_pos = out.index("[coach@claw ~]$")
703
+ row_pos = out.index("safe_git_hygiene")
704
+ levelup_pos = out.index("UPLINK")
705
+ breach_pos = out.index("next breach ๐Ÿ”“")
706
+ assert header_pos < row_pos < levelup_pos < breach_pos
707
+
708
+
709
+ def test_hacker_slugs_do_not_leak_kebab_form():
710
+ """The slug 'safe-git-hygiene' should not appear; only the snake_case
711
+ transformed name 'safe_git_hygiene' should."""
712
+ out = render_celebrate_for_theme(
713
+ "hacker",
714
+ streak_rewards=_hacker_streak_fixture(),
715
+ levelup=None,
716
+ now=NOW,
717
+ streak_oldest=YESTERDAY,
718
+ )
719
+ assert "safe-git-hygiene" not in out
720
+ assert "heavy-subagent-delegation" not in out
721
+ assert "safe_git_hygiene" in out
722
+ assert "heavy_subagent_delegation" in out
723
+
724
+
725
+ # -----------------------------------------------------------------------------
726
+ # Military theme โ€” divergent shape (tag-prefixed rows, rank ribbon footer).
727
+ # Pins the SITREP header, [PUSH]/[HOLD] tag rows, and the rank ribbon line
728
+ # that pulls medal_count + Roman numeral + ELO from stats.compute_for_render.
729
+
730
+ def _military_streak_fixture():
731
+ return [
732
+ {"id": "safe-git-hygiene", "name": "safe git hygiene",
733
+ "streak": 4, "target": 5, "xp_awarded": 2, "direction": "positive"},
734
+ {"id": "good-debugging", "name": "good debugging",
735
+ "streak": 2, "target": 5, "xp_awarded": 1, "direction": "positive"},
736
+ {"id": "heavy-subagent-delegation", "name": "heavy subagent delegation",
737
+ "streak": 4, "target": 5, "xp_awarded": 2, "direction": "negative"},
738
+ {"id": "commit-without-testing", "name": "commit without testing",
739
+ "streak": 3, "target": 5, "xp_awarded": 1, "direction": "negative"},
740
+ ]
741
+
742
+
743
+ def test_military_header_uses_sitrep_with_now_date_and_zulu_time():
744
+ out = render_celebrate_for_theme(
745
+ "military",
746
+ streak_rewards=_military_streak_fixture(),
747
+ levelup=None,
748
+ now=NOW,
749
+ streak_oldest=YESTERDAY,
750
+ )
751
+ assert out is not None
752
+ # SITREP uses *current* time (now), not oldest_entry_at.
753
+ assert "> โ—ข SITREP ยท 2026-05-07 ยท 1744Z" in out
754
+
755
+
756
+ def test_military_rows_use_push_hold_tags_and_xp_unit():
757
+ out = render_celebrate_for_theme(
758
+ "military",
759
+ streak_rewards=_military_streak_fixture(),
760
+ levelup=None,
761
+ now=NOW,
762
+ streak_oldest=YESTERDAY,
763
+ )
764
+ assert "[PUSH] โ–ฎโ–ฎโ–ฎโ–ฎโ–ฏ safe git hygiene" in out
765
+ assert "[HOLD] โ–ฎโ–ฎโ–ฎโ–ฎโ–ฏ heavy subagent delegation" in out
766
+ # XP format includes 'XP' suffix for military (verb-style omits it).
767
+ assert "โ†‘2 XP" in out
768
+ assert "โ†“2 XP" in out
769
+ # Verb-style markers must NOT leak.
770
+ assert "tempering" not in out
771
+ assert "rising tide" not in out
772
+ assert "oath kept" not in out
773
+
774
+
775
+ def test_military_rank_ribbon_at_l8():
776
+ """At L8 lifetime=90: medal_count=2, roman=โ…ง, elo=1257 (default
777
+ 1000-2800 range), name=Sensei, next_xp=125."""
778
+ out = render_celebrate_for_theme(
779
+ "military",
780
+ streak_rewards=[],
781
+ levelup={"to": "Sensei", "to_idx": 7, "xp_at_levelup": 90},
782
+ now=NOW,
783
+ streak_oldest=None,
784
+ )
785
+ assert out is not None
786
+ # Two medals at L8.
787
+ assert "๐ŸŽ–๏ธ๐ŸŽ–๏ธ" in out
788
+ # Roman numeral + ELO + bold name + next-promotion threshold.
789
+ assert "โ…ง" in out
790
+ assert "**Sensei**" in out
791
+ assert "promotion at 125 XP" in out
792
+ # Lozenge sigil opens the rank line. 2-space indent matches the
793
+ # locked footer cadence (compare to the verb-style footer indent).
794
+ assert "> โ—† ๐ŸŽ–๏ธ๐ŸŽ–๏ธ โ…ง 1257 **Sensei**" in out
795
+
796
+
797
+ def test_military_rank_ribbon_caps_at_5_medals_at_high_levels():
798
+ """At L20+ medal_count is clamped to 5 โ€” verifies the rank-ribbon
799
+ scaling locked in compute_for_render."""
800
+ out = render_celebrate_for_theme(
801
+ "military",
802
+ streak_rewards=[],
803
+ levelup={"to": "Paragon", "to_idx": 19, "xp_at_levelup": 840},
804
+ now=NOW,
805
+ streak_oldest=None,
806
+ )
807
+ assert out is not None
808
+ assert "๐ŸŽ–๏ธ๐ŸŽ–๏ธ๐ŸŽ–๏ธ๐ŸŽ–๏ธ๐ŸŽ–๏ธ" in out
809
+ # No 6-medal regression.
810
+ assert "๐ŸŽ–๏ธ๐ŸŽ–๏ธ๐ŸŽ–๏ธ๐ŸŽ–๏ธ๐ŸŽ–๏ธ๐ŸŽ–๏ธ" not in out
811
+
812
+
813
+ def test_military_streak_only_emits_no_rank_line():
814
+ """No levelup โ†’ no rank ribbon. Just SITREP header + rows."""
815
+ out = render_celebrate_for_theme(
816
+ "military",
817
+ streak_rewards=_military_streak_fixture(),
818
+ levelup=None,
819
+ now=NOW,
820
+ streak_oldest=YESTERDAY,
821
+ )
822
+ assert out is not None
823
+ assert "๐ŸŽ–๏ธ" not in out
824
+ assert "promotion at" not in out
825
+
826
+
827
+ def test_military_full_block_streak_plus_levelup_ordering():
828
+ out = render_celebrate_for_theme(
829
+ "military",
830
+ streak_rewards=_military_streak_fixture(),
831
+ levelup={"to": "Sensei", "to_idx": 7, "xp_at_levelup": 90},
832
+ now=NOW,
833
+ streak_oldest=YESTERDAY,
834
+ )
835
+ assert out is not None
836
+ sitrep_pos = out.index("SITREP")
837
+ push_row_pos = out.index("[PUSH]")
838
+ rank_line_pos = out.index("๐ŸŽ–๏ธ")
839
+ assert sitrep_pos < push_row_pos < rank_line_pos
840
+
841
+
842
+ def test_military_uses_levelup_to_name_when_present():
843
+ """If `levelup['to']` is present, prefer it over compute_for_render's
844
+ LEVELS lookup. Lets a marker written under one theme still render its
845
+ captured rank name even if the user has switched themes since."""
846
+ out = render_celebrate_for_theme(
847
+ "military",
848
+ streak_rewards=[],
849
+ levelup={"to": "Sergeantmajor", "to_idx": 7, "xp_at_levelup": 90},
850
+ now=NOW,
851
+ streak_oldest=None,
852
+ )
853
+ assert out is not None
854
+ assert "**Sergeantmajor**" in out
855
+
856
+
857
+ # -----------------------------------------------------------------------------
858
+ # Catch-up framing โ€” when caught_up=True, the disclaimer line should
859
+ # emit ONLY when no streak header is present (the streak header carries
860
+ # the date phrasing for streak banners; levelup-only / grad-only /
861
+ # reg-only bespoke banners have no header to do that work).
862
+
863
+ def test_caught_up_with_streak_does_not_emit_framing_line():
864
+ """Streak banner has 'Tide turned ยท since X' โ€” framing line stays
865
+ suppressed. This is the locked v1 decision."""
866
+ out = render_celebrate_for_theme(
867
+ "ocean",
868
+ streak_rewards=_ocean_streak_fixture(),
869
+ levelup=None,
870
+ now=NOW,
871
+ streak_oldest=YESTERDAY,
872
+ caught_up=True,
873
+ )
874
+ assert out is not None
875
+ assert "Milestones earned across earlier sessions" not in out
876
+
877
+
878
+ def test_caught_up_levelup_only_emits_framing_line():
879
+ """Levelup-only bespoke banner has no theme header โ†’ framing line
880
+ earns its keep, telling the user 'this isn't from the prompt you
881
+ just typed'."""
882
+ out = render_celebrate_for_theme(
883
+ "ocean",
884
+ streak_rewards=[],
885
+ levelup={"to": "Reefer", "to_idx": 7, "xp_at_levelup": 90},
886
+ now=NOW,
887
+ streak_oldest=None,
888
+ caught_up=True,
889
+ )
890
+ assert out is not None
891
+ assert "Milestones earned across earlier sessions" in out
892
+
893
+
894
+ def test_caught_up_grad_only_emits_framing_line():
895
+ """Grad-only bespoke banner: same logic โ€” no streak header, framing
896
+ line should appear."""
897
+ grads_block = "> ๐ŸŽ“โšก๏ธ **GRADUATED: skipped search tools** `+5 XP`"
898
+ out = render_celebrate_for_theme(
899
+ "skyrim",
900
+ streak_rewards=[],
901
+ levelup=None,
902
+ grads_block=grads_block,
903
+ now=NOW,
904
+ streak_oldest=None,
905
+ caught_up=True,
906
+ )
907
+ assert out is not None
908
+ assert "Milestones earned across earlier sessions" in out
909
+
910
+
911
+ def test_caught_up_false_never_emits_framing_line():
912
+ """caught_up=False โ†’ framing line never appears, regardless of
913
+ section composition."""
914
+ out = render_celebrate_for_theme(
915
+ "ocean",
916
+ streak_rewards=[],
917
+ levelup={"to": "Reefer", "to_idx": 7, "xp_at_levelup": 90},
918
+ now=NOW,
919
+ streak_oldest=None,
920
+ caught_up=False,
921
+ )
922
+ assert out is not None
923
+ assert "Milestones earned across earlier sessions" not in out
924
+
925
+
926
+ # -----------------------------------------------------------------------------
927
+ # L50 max-rank handling โ€” at the cap, "next at X XP" is wrong (compute_
928
+ # for_render returns None, _next_xp_after_levelup returns 0). Each theme
929
+ # swaps to a max-rank suffix that doesn't promise more progression.
930
+
931
+ @pytest.mark.parametrize("theme,expected_max_phrase,forbidden", [
932
+ ("forge", "the forge is mastered", "next heat at"),
933
+ ("ocean", "all fathoms reached", "next fathom at"),
934
+ ("skyrim", "saga complete", "next title at"),
935
+ ])
936
+ def test_verb_style_l50_uses_max_template(theme, expected_max_phrase, forbidden):
937
+ """At to_idx=49 (L50), the level-up footer swaps to the max template
938
+ so it doesn't render 'next heat at 0 XP' / 'next fathom at 0 XP'."""
939
+ out = render_celebrate_for_theme(
940
+ theme,
941
+ streak_rewards=[],
942
+ levelup={"to": "Origin", "to_idx": 49, "xp_at_levelup": 5865},
943
+ now=NOW,
944
+ streak_oldest=None,
945
+ )
946
+ assert out is not None
947
+ assert expected_max_phrase in out
948
+ # No "next at 0 XP" โ€” the bug being guarded against.
949
+ assert forbidden not in out
950
+ assert "0 XP" not in out
951
+
952
+
953
+ def test_military_l50_uses_highest_grade_suffix():
954
+ """compute_for_render returns next_xp=None at L50 โ€” military must
955
+ NOT format that None into the string. Render 'highest grade' instead."""
956
+ out = render_celebrate_for_theme(
957
+ "military",
958
+ streak_rewards=[],
959
+ levelup={"to": "Polemarch", "to_idx": 49, "xp_at_levelup": 5865},
960
+ now=NOW,
961
+ streak_oldest=None,
962
+ )
963
+ assert out is not None
964
+ assert "highest grade" in out
965
+ # The previously-broken paths.
966
+ assert "promotion at None" not in out
967
+ assert "promotion at 0" not in out
968
+
969
+
970
+ def test_hacker_l50_uses_root_access_line():
971
+ """Hacker drops 'next breach ๐Ÿ”“ 0 xp' for a max-rank line."""
972
+ out = render_celebrate_for_theme(
973
+ "hacker",
974
+ streak_rewards=[],
975
+ levelup={"to": "Singularity", "to_idx": 49, "xp_at_levelup": 5865},
976
+ now=NOW,
977
+ streak_oldest=None,
978
+ )
979
+ assert out is not None
980
+ assert "root access ๐Ÿ”“ max layer reached" in out
981
+ assert "next breach ๐Ÿ”“ 0" not in out