mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-08-11 11:19:24 +00:00
* feat(achievements): wall sync drain worker (epic PR3, client side) Background dead-letter worker that POSTs queued Feat unlocks/removals to the hosted feedback-achievements wall. Idle unless FEEDBACK_ACHIEVEMENTS_WALL_URL is set; uses requests + the client-token header (mirrors lyrics_transcribe). Dead-letter, never drop (pure engine.drain_decision): network err / 429 / 5xx -> keep pending (retry) other 4xx -> dead_letter (diagnosable, replayable) 2xx -> delete on server ack remove-me enqueues a wall removal keyed by the reused player_hash. Verified by an end-to-end staging round-trip (earn a Feat -> drains onto the wall with name + short hash -> remove-me -> wall empties) with no IP in tables or access logs. 42 plugin tests pass (test_sync.py adds the decision table + ack/retry/dead-letter retention + four-field on-the-wire payload). The hosted service lives in the new feedback-achievements repo. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(achievements): address local review findings (epic) Bugs caught in the pre-merge review loop: - secret_witching Feat was DEAD: post_activity wrote witching_nights_run to the DB before snapshotting prev_tiers, so diff_unlocks never saw the fresh unlock. Fold the run into the activity delta instead (same asymmetry chart_encore uses) so the 7th-night unlock is detected. +regression tests. - chart_encore broke across restarts: per-chart counter keyed on abs(hash(str)), which Python salts per-process (PYTHONHASHSEED). Use a stable sha1 digest so the same chart accumulates across sessions. +regression test. - Bounded the per-activity counter read: _read_counters no longer pulls the unbounded chart_plays:* rows (they're bumped/read individually). - screen.js: gate note:hit/miss on an active-song flag so tuner/calibration note events can't inflate Feats or flush a phantom chart:null session. - screen.js: P-III — prefix the plugin localStorage key (achievements:profile-cat). - screen.js: extract the duplicated local-ISO-date helper. 45 plugin tests pass (3 new). Wall-side review fixes are in the feedback-achievements repo. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(achievements): default the drain worker to the hosted wall Point FEEDBACK_ACHIEVEMENTS_WALL_URL's default at the live got-feedback wall (https://feedback-achievements.onrender.com) so the drain worker targets it out of the box; still env-overridable for self-hosting/staging. Nothing publishes unless the user opted in AND has a profile identity, so a default URL alone sends nothing. Tests disable the default (autouse fixture) so no test ever POSTs to production; drain logic is covered via _drain_once() with an injected poster. 45 pass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
91 lines
4.4 KiB
Python
91 lines
4.4 KiB
Python
"""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_witching_feat_unlocks_on_seventh_consecutive_night(client):
|
|
# Regression: the derived witching_nights_run counter must NOT be pre-written
|
|
# before the prev snapshot, or diff_unlocks never sees the fresh unlock.
|
|
unlocked_ever = []
|
|
for day in range(1, 8):
|
|
res = client.post("/api/plugins/achievements/activity",
|
|
json={"night_session": True, "night_date": "2026-06-%02d" % day}).json()
|
|
unlocked_ever += [u["id"] for u in res["unlocked"]]
|
|
assert "secret_witching" in unlocked_ever, "witching feat never reported as unlocked"
|
|
feats = [f["id"] for f in client.get("/api/plugins/achievements/feats").json()["feats"]]
|
|
assert "secret_witching" in feats
|
|
|
|
|
|
def test_witching_not_unlocked_before_seven(client):
|
|
for day in range(1, 7): # only 6 nights
|
|
client.post("/api/plugins/achievements/activity",
|
|
json={"night_session": True, "night_date": "2026-06-%02d" % day})
|
|
feats = [f["id"] for f in client.get("/api/plugins/achievements/feats").json()["feats"]]
|
|
assert "secret_witching" not in feats
|
|
|
|
|
|
def test_chart_key_is_stable_not_builtin_hash(client):
|
|
import hashlib
|
|
import routes
|
|
# Deterministic across processes (sha1-based), unlike the salted builtin hash().
|
|
assert routes._chart_key("song.sloppak") == "chart_plays:" + hashlib.sha1(b"song.sloppak").hexdigest()[:16]
|
|
assert routes._chart_key("a") != routes._chart_key("b")
|
|
|
|
|
|
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
|