feedBack/tests/plugins/achievements/test_engine.py
Byron Gamatos 05dd3d227a
feat(achievements): local engine + tabbed Profile shell (epic PR1) (#587)
Adds the Achievements & Feats of Power local engine, fully offline.

Core (static/v3/profile.js): the Profile screen becomes tabbed exactly
like v3 Settings (.fb-tabbar/.fb-tab/.fb-tabpanel, active tab persisted in
localStorage 'v3-profile-tab'). A Profile (main) tab carries the existing
cards + a Feats trophy-shelf mount (#v3-profile-feats-slot, earned-only),
and an Achievements tab carries a plugin mount
(#v3-profile-achievements-mount) + empty-state note. A new
`v3:profile-rendered` event fires after every render so the plugin
re-injects (mirrors v3:settings-rendered).

New bundled plugin (plugins/achievements/): SQLite engine
(unlocks/counters/comp_ledger/sync_queue) with pure threshold/criterion
math in the testable sibling engine.py (P-V); routes activity/
report-unlock/report-criterion/catalog/earned/feats/remove-me. Feats read
activity counters only (batched song:ended POST; notes only when notedetect
present — graceful degradation); competency Achievements evaluate from
progression events only — the integration law, never crossed. Catalogue is
always shown (locked=greyed), grouped by the real progression paths
(Global/Guitar/Bass/Drums/Keys, auto-extending) with per-category earned
badges. Versioned window.feedBack.achievements registration API with the
__feedBackAchievementsPending load-order queue + achievements:ready event.

Verified natively (uvicorn) end-to-end + Playwright (tabbar, earned-only
Feats shelf, greyed catalogue, registration API, zero console errors);
24 plugin tests pass incl. the integration-law assertion.

Opt-in/privacy/data-min gate (PR2) and the hosted wall (PR3) follow.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 16:57:37 +02:00

92 lines
3.2 KiB
Python

"""Pure-helper unit tests for the achievements engine (no IO, P-V)."""
import engine
class TestTierIndexFor:
def test_below_first_tier(self):
assert engine.tier_index_for([100000, 1000000], 50000) == -1
def test_exact_threshold(self):
assert engine.tier_index_for([100000, 1000000], 100000) == 0
def test_highest_reached(self):
assert engine.tier_index_for([100000, 1000000, 10000000], 2000000) == 1
def test_all_tiers(self):
assert engine.tier_index_for([100, 500], 9999) == 1
def test_empty_tiers(self):
assert engine.tier_index_for([], 10) == -1
class TestApplyActivity:
def test_cumulative_adds(self):
c = engine.apply_activity({}, {"notes": 10, "song_done": 1, "seconds": 30})
assert c["notes_total"] == 10
assert c["songs_done"] == 1
assert c["time_total_seconds"] == 30
c = engine.apply_activity(c, {"notes": 5, "song_done": 1, "seconds": 20})
assert c["notes_total"] == 15
assert c["songs_done"] == 2
assert c["time_total_seconds"] == 50
def test_max_counters_take_maximum(self):
c = engine.apply_activity({}, {"session_notes": 100, "in_song_streak": 40})
c = engine.apply_activity(c, {"session_notes": 60, "in_song_streak": 90})
assert c["notes_session_max"] == 100
assert c["streak_insong_max"] == 90
def test_chart_encore_only_when_present(self):
c = engine.apply_activity({}, {"notes": 1})
assert "chart_encore_max" not in c
c = engine.apply_activity(c, {"chart_play_count": 7})
assert c["chart_encore_max"] == 7
def test_is_pure(self):
before = {"notes_total": 5}
engine.apply_activity(before, {"notes": 100})
assert before == {"notes_total": 5} # input unmutated
class TestEvaluateFeats:
FEATS = [
{"id": "notes_total", "counter": "notes_total", "tiers": [100000, 1000000]},
{"id": "songs_done", "counter": "songs_done", "tiers": [1000, 5000]},
{"id": "secret_combo", "counter": None, "tiers": []},
]
def test_unmet_omitted(self):
assert engine.evaluate_feats(self.FEATS, {"notes_total": 50000}) == {}
def test_met_tier(self):
out = engine.evaluate_feats(self.FEATS, {"notes_total": 2000000, "songs_done": 1200})
assert out == {"notes_total": 1, "songs_done": 0}
def test_no_counter_feat_never_auto_unlocks(self):
out = engine.evaluate_feats(self.FEATS, {"notes_total": 99999999})
assert "secret_combo" not in out
class TestDiffUnlocks:
def test_first_unlock(self):
assert engine.diff_unlocks({}, {"a": 0}) == ["a"]
def test_tier_advance(self):
assert engine.diff_unlocks({"a": 0}, {"a": 1}) == ["a"]
def test_no_change(self):
assert engine.diff_unlocks({"a": 1}, {"a": 1}) == []
class TestConsecutiveRun:
def test_seven_consecutive(self):
dates = ["2026-06-0%d" % d for d in range(1, 8)]
assert engine.consecutive_run_length(dates) == 7
def test_break_resets(self):
assert engine.consecutive_run_length(["2026-06-01", "2026-06-02", "2026-06-05"]) == 2
def test_dedup_and_unsorted(self):
assert engine.consecutive_run_length(["2026-06-03", "2026-06-01", "2026-06-02", "2026-06-02"]) == 3