mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-08-11 03:09:57 +00:00
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>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
873ee3d5f2
commit
05dd3d227a
@@ -0,0 +1,17 @@
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent / 'plugins' / 'achievements'))
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
import routes as ach_routes
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(tmp_path):
|
||||
app = FastAPI()
|
||||
ach_routes.setup(app, {"config_dir": str(tmp_path)})
|
||||
return TestClient(app)
|
||||
@@ -0,0 +1,91 @@
|
||||
"""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
|
||||
@@ -0,0 +1,61 @@
|
||||
"""HTTP-level tests for the achievements engine, incl. the integration law."""
|
||||
|
||||
|
||||
def test_catalog_ships_baseline(client):
|
||||
data = client.get("/api/plugins/achievements/catalog").json()
|
||||
assert "baseline" in data
|
||||
ids = [d["id"] for d in data["baseline"].get("global", [])]
|
||||
assert "first_steps" in ids and "ascendant" in ids
|
||||
|
||||
|
||||
def test_activity_unlocks_feat_and_appears_on_shelf(client):
|
||||
# 100k notes in one shot crosses notes_total tier 0 (Note Hunter).
|
||||
res = client.post("/api/plugins/achievements/activity", json={"notes": 100000}).json()
|
||||
assert res["ok"] is True
|
||||
unlocked_ids = [u["id"] for u in res["unlocked"]]
|
||||
assert "notes_total" in unlocked_ids
|
||||
# And it shows on the Feats shelf.
|
||||
feats = client.get("/api/plugins/achievements/feats").json()["feats"]
|
||||
assert any(f["id"] == "notes_total" for f in feats)
|
||||
|
||||
|
||||
def test_activity_below_threshold_unlocks_nothing(client):
|
||||
res = client.post("/api/plugins/achievements/activity", json={"notes": 50000}).json()
|
||||
assert res["unlocked"] == []
|
||||
assert client.get("/api/plugins/achievements/feats").json()["feats"] == []
|
||||
|
||||
|
||||
def test_integration_law_competency_never_on_feat_shelf(client):
|
||||
# A competency unlock reported by a source must NEVER appear among Feats.
|
||||
client.post("/api/plugins/achievements/report-unlock", json={
|
||||
"id": "tempo_push", "kind": "achievement", "category": "guitar", "sourceId": "virtuoso"})
|
||||
feats = client.get("/api/plugins/achievements/feats").json()["feats"]
|
||||
assert all(f["id"] != "tempo_push" for f in feats)
|
||||
# But it is earned (competency class).
|
||||
earned = client.get("/api/plugins/achievements/earned").json()["earned"]
|
||||
rec = [e for e in earned if e["id"] == "tempo_push"]
|
||||
assert rec and rec[0]["cls"] == "competency"
|
||||
|
||||
|
||||
def test_report_unlock_is_idempotent_and_tier_monotonic(client):
|
||||
body = {"id": "ascendant", "kind": "achievement", "category": "global", "tier": 1}
|
||||
first = client.post("/api/plugins/achievements/report-unlock", json=body).json()
|
||||
assert first["changed"] is True
|
||||
# Same tier again → no change.
|
||||
again = client.post("/api/plugins/achievements/report-unlock", json=body).json()
|
||||
assert again["changed"] is False
|
||||
# Lower tier → still no change (monotonic).
|
||||
lower = client.post("/api/plugins/achievements/report-unlock",
|
||||
json={**body, "tier": 0}).json()
|
||||
assert lower["changed"] is False
|
||||
# Higher tier → advances.
|
||||
higher = client.post("/api/plugins/achievements/report-unlock",
|
||||
json={**body, "tier": 2}).json()
|
||||
assert higher["changed"] is True
|
||||
|
||||
|
||||
def test_report_criterion_counts_distinct(client):
|
||||
url = "/api/plugins/achievements/report-criterion"
|
||||
assert client.post(url, json={"criterion_id": "x", "token": "a"}).json()["count"] == 1
|
||||
assert client.post(url, json={"criterion_id": "x", "token": "a"}).json()["count"] == 1 # dup
|
||||
assert client.post(url, json={"criterion_id": "x", "token": "b"}).json()["count"] == 2
|
||||
Reference in New Issue
Block a user