@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.
- package/LICENSE +21 -0
- package/README.md +311 -0
- package/coach/README.md +99 -0
- package/coach/bin/aggregate_facets.py +274 -0
- package/coach/bin/analyze.py +678 -0
- package/coach/bin/bank.py +247 -0
- package/coach/bin/banner_themes.py +645 -0
- package/coach/bin/coach_paths.py +33 -0
- package/coach/bin/coexistence_check.py +129 -0
- package/coach/bin/configure.py +245 -0
- package/coach/bin/cron_check.py +81 -0
- package/coach/bin/default_statusline.py +135 -0
- package/coach/bin/doctor.py +663 -0
- package/coach/bin/insights-llm.sh +264 -0
- package/coach/bin/insights.sh +163 -0
- package/coach/bin/insights_window.py +111 -0
- package/coach/bin/marker_io.py +154 -0
- package/coach/bin/merge.py +671 -0
- package/coach/bin/redact.py +86 -0
- package/coach/bin/render_env.py +148 -0
- package/coach/bin/reward_hints.py +87 -0
- package/coach/bin/run-insights.sh +20 -0
- package/coach/bin/run_with_lock.py +85 -0
- package/coach/bin/scoring.py +260 -0
- package/coach/bin/skill_inventory.py +215 -0
- package/coach/bin/stats.py +459 -0
- package/coach/bin/status.py +293 -0
- package/coach/bin/statusline_self_patch.py +205 -0
- package/coach/bin/statusline_variants.py +146 -0
- package/coach/bin/statusline_wrap.py +244 -0
- package/coach/bin/statusline_wrap_action.py +460 -0
- package/coach/bin/switch_to_plugin.py +256 -0
- package/coach/bin/themes.py +256 -0
- package/coach/bin/user_config.py +176 -0
- package/coach/bin/xp_accounting.py +98 -0
- package/coach/changelog.md +4 -0
- package/coach/default-statusline-command.sh +19 -0
- package/coach/default-statusline-wrap-command.sh +15 -0
- package/coach/profile.yaml +37 -0
- package/coach/tests/conftest.py +13 -0
- package/coach/tests/test_aggregate_facets.py +379 -0
- package/coach/tests/test_analyze_aggregate.py +153 -0
- package/coach/tests/test_analyze_redaction.py +105 -0
- package/coach/tests/test_analyze_strengths.py +165 -0
- package/coach/tests/test_bank_atomic_write.py +61 -0
- package/coach/tests/test_bank_concurrency.py +126 -0
- package/coach/tests/test_banner_themes.py +981 -0
- package/coach/tests/test_celebrate_dedup.py +409 -0
- package/coach/tests/test_coach_paths.py +50 -0
- package/coach/tests/test_coexistence_check.py +128 -0
- package/coach/tests/test_configure.py +258 -0
- package/coach/tests/test_cron_check.py +118 -0
- package/coach/tests/test_cron_nudge_hook.py +134 -0
- package/coach/tests/test_detection_parity.py +105 -0
- package/coach/tests/test_doctor.py +595 -0
- package/coach/tests/test_hook_bespoke_dispatch.py +288 -0
- package/coach/tests/test_hook_module_resolution.py +116 -0
- package/coach/tests/test_hook_relevance.py +996 -0
- package/coach/tests/test_hook_render_env.py +364 -0
- package/coach/tests/test_hook_session_id_guard.py +160 -0
- package/coach/tests/test_insights_llm.py +759 -0
- package/coach/tests/test_insights_llm_venv_path.py +109 -0
- package/coach/tests/test_insights_window.py +237 -0
- package/coach/tests/test_install.py +1150 -0
- package/coach/tests/test_install_pyyaml_fallback.py +142 -0
- package/coach/tests/test_marker_consumption.py +167 -0
- package/coach/tests/test_marker_writer_locking.py +305 -0
- package/coach/tests/test_merge.py +413 -0
- package/coach/tests/test_no_broken_mktemp.py +90 -0
- package/coach/tests/test_render_env.py +137 -0
- package/coach/tests/test_render_env_glyphs.py +119 -0
- package/coach/tests/test_reward_hints.py +59 -0
- package/coach/tests/test_scoring.py +147 -0
- package/coach/tests/test_session_start_weekly_trigger.py +92 -0
- package/coach/tests/test_skill_inventory.py +368 -0
- package/coach/tests/test_stats_hybrid.py +142 -0
- package/coach/tests/test_status_accounting.py +41 -0
- package/coach/tests/test_statusline_failsafe.py +70 -0
- package/coach/tests/test_statusline_self_patch.py +261 -0
- package/coach/tests/test_statusline_variants.py +110 -0
- package/coach/tests/test_statusline_wrap.py +196 -0
- package/coach/tests/test_statusline_wrap_action.py +408 -0
- package/coach/tests/test_switch_to_plugin.py +360 -0
- package/coach/tests/test_themes.py +104 -0
- package/coach/tests/test_user_config.py +160 -0
- package/coach/tests/test_wrap_announce_hook.py +130 -0
- package/coach/tests/test_xp_accounting.py +55 -0
- package/hooks/coach-session-start.py +536 -0
- package/hooks/coach-user-prompt.py +2288 -0
- package/install-launchd.sh +102 -0
- package/install.sh +597 -0
- package/launchd/com.local.claude-coach.plist.template +34 -0
- package/launchd/run-insights.sh +20 -0
- package/npm/coach-claw.js +259 -0
- package/package.json +52 -0
- package/requirements.txt +11 -0
- package/settings-snippet.json +31 -0
- package/skills/coach/SKILL.md +107 -0
- package/skills/coach-insights/SKILL.md +78 -0
- 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
|