From 13e68c2f833750320b04d240ddf15b706b1ece02 Mon Sep 17 00:00:00 2001 From: byrongamatos Date: Mon, 13 Jul 2026 14:41:42 +0200 Subject: [PATCH] =?UTF-8?q?feat(career):=20hours-per-genre=20odometer=20?= =?UTF-8?q?=E2=80=94=20honest=20wall-clock=20play=20time?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Career v2, WS2. Nothing measured play time before (the achievements plugin's final-position shortcut double-counts loops and mis-reads seeks). Now: - stats-recorder.js accrues WALL-CLOCK seconds across song:play/resume ↔ pause/stop/ended spans (single spans clamp at 2h against suspend inflation) and piggybacks them as `seconds` on the POSTs it already sends; failed POSTs restore the accumulator; a session reset flushes first so time can't re-attribute to the next song/arrangement. - POST /api/stats accepts optional `seconds` (finite, 0 < s ≤ 6h) on the scored and position branches, plus a new seconds-only branch for unscored plays that ran to the natural end — banks time WITHOUT touching the resume position (song:ended must not overwrite Continue) and still counts as playing today for the streak. - song_stats gains additive idempotent `seconds_total`; record_session/ touch_position accrue, new add_play_seconds() for the seconds-only path; the legacy-encoding stats merge sums seconds across duplicates. - Passports surface it: "14.2 h in Blues" under the badge stamp and on the shelf cover sub-line — a true fact that only grows, never a target or a meter (Stage 5 post-cap, per the career design). Tests: seconds accrual/validation/seconds-only branch (stats API), per-instrument-and-genre summing (career), fmtHours formatting (vm). Full suites: pytest 2480, JS 1165. Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 11 ++++ lib/metadata_db.py | 57 ++++++++++++++---- lib/routers/stats.py | 36 +++++++++++- plugins/career/assets/career.css | 9 +++ plugins/career/routes.py | 26 ++++++--- plugins/career/screen.js | 18 +++++- plugins/career/tests/passports.test.js | 12 ++++ static/v3/stats-recorder.js | 80 +++++++++++++++++++++++++- tests/plugins/career/conftest.py | 10 ++-- tests/plugins/career/test_passports.py | 12 ++++ tests/test_song_stats_api.py | 49 ++++++++++++++++ 11 files changed, 289 insertions(+), 31 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0d6f57d..d181b63 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 Click or wait ~4s to dismiss; `prefers-reduced-motion` gets the existing chime + notification only. The stamp still slams into the passport book on next open, unchanged. +- **Hours-per-genre odometer (career passports)** — the app now measures real + play time: the stats recorder accrues **wall-clock** seconds across + play/resume ↔ pause/stop/end spans (wall time, not song position — position + deltas double-count A-B loops and mis-read seeks; single spans clamp at 2h + against suspend/sleep inflation) and piggybacks them as `seconds` on the + `POST /api/stats` calls it already makes. New additive + `song_stats.seconds_total` column; a seconds-only POST banks time for + unscored plays that run to the song's natural end without touching the + resume position (and still counts as playing today for the streak). + Passports surface it honestly: "14.2 h in Blues" under the badge and on the + shelf cover — a true fact that only grows, never a target or a meter. - **Career passports (backend)** — the badge-journey layer on top of career stars. New career-plugin endpoints: `GET /api/plugins/career/passports` (per-instrument passport walls: genre badges computed on read from `song_stats` × the library's diff --git a/lib/metadata_db.py b/lib/metadata_db.py index 4f538e5..29a3f15 100644 --- a/lib/metadata_db.py +++ b/lib/metadata_db.py @@ -614,6 +614,14 @@ class MetadataDB: ) """) self.conn.execute("CREATE INDEX IF NOT EXISTS idx_song_stats_recent ON song_stats(last_played_at DESC)") + # Cumulative wall-clock play time (career "hours in genre" odometer). + # Fed by the same POST /api/stats the recorder already sends; additive + # + idempotent like every other song_stats change. + try: + self.conn.execute( + "ALTER TABLE song_stats ADD COLUMN seconds_total REAL NOT NULL DEFAULT 0") + except sqlite3.OperationalError: + pass # Playlists + the reserved "Saved for Later" system playlist. Additive. self.conn.execute(""" CREATE TABLE IF NOT EXISTS playlists ( @@ -901,6 +909,9 @@ class MetadataDB: "best_accuracy": max(cur["best_accuracy"] or 0.0, r["best_accuracy"] or 0.0), "last_score": newer["last_score"], "last_accuracy": newer["last_accuracy"], "last_position": newer["last_position"], + # Play time is additive: both encodings' hours belong to + # the one canonical song. + "seconds_total": (cur.get("seconds_total") or 0.0) + (r.get("seconds_total") or 0.0), "last_played_at": newer["last_played_at"], "updated_at": newer["updated_at"], } # Atomic swap: clear and reinsert the canonicalized set in one txn. @@ -1693,7 +1704,8 @@ class MetadataDB: # ── Per-song practice stats ───────────────────────────────────────────--- _STATS_COLS = ( "filename", "arrangement", "plays", "best_score", "best_accuracy", - "last_score", "last_accuracy", "last_position", "last_played_at", "updated_at", + "last_score", "last_accuracy", "last_position", "seconds_total", + "last_played_at", "updated_at", ) def _stats_row(self, filename: str, arrangement: int) -> dict | None: @@ -2060,8 +2072,9 @@ class MetadataDB: self.conn.commit() def record_session(self, filename: str, arrangement: int, *, score: int, - accuracy: float, last_position=None) -> dict: - """Record a scored play: plays += 1, best_* = max, last_* = new.""" + accuracy: float, last_position=None, seconds: float = 0) -> dict: + """Record a scored play: plays += 1, best_* = max, last_* = new. + `seconds` (wall-clock play time from the recorder) accrues.""" from song_score import merge_stats with self._lock: existing = self._stats_row(filename, int(arrangement)) @@ -2071,8 +2084,9 @@ class MetadataDB: self.conn.execute( """INSERT INTO song_stats (filename, arrangement, plays, best_score, best_accuracy, - last_score, last_accuracy, last_position, last_played_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, + last_score, last_accuracy, last_position, seconds_total, + last_played_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, strftime('%Y-%m-%d %H:%M:%f','now'), strftime('%Y-%m-%d %H:%M:%f','now')) ON CONFLICT(filename, arrangement) DO UPDATE SET plays = excluded.plays, @@ -2081,32 +2095,53 @@ class MetadataDB: last_score = excluded.last_score, last_accuracy = excluded.last_accuracy, last_position = excluded.last_position, + seconds_total = song_stats.seconds_total + excluded.seconds_total, last_played_at = excluded.last_played_at, updated_at = excluded.updated_at""", (filename, int(arrangement), merged["plays"], merged["best_score"], merged["best_accuracy"], merged["last_score"], merged["last_accuracy"], - merged["last_position"]), + merged["last_position"], float(seconds or 0)), ) self.conn.commit() return self._stats_row(filename, int(arrangement)) - def touch_position(self, filename: str, arrangement: int, last_position: float) -> dict: + def touch_position(self, filename: str, arrangement: int, last_position: float, + seconds: float = 0) -> dict: """Persist just the resume position (no plays/score change), so Continue-Playing works for non-scored plays. Also stamps last_played_at — both /api/stats/recent and /api/session/continue filter/order on it, so a position-only touch must set it or the song - never surfaces as 'recent' / 'continue playing'.""" + never surfaces as 'recent' / 'continue playing'. `seconds` accrues + wall-clock play time (career hours odometer).""" with self._lock: self.conn.execute( """INSERT INTO song_stats (filename, arrangement, last_position, - last_played_at, updated_at) - VALUES (?, ?, ?, strftime('%Y-%m-%d %H:%M:%f','now'), + seconds_total, last_played_at, updated_at) + VALUES (?, ?, ?, ?, strftime('%Y-%m-%d %H:%M:%f','now'), strftime('%Y-%m-%d %H:%M:%f','now')) ON CONFLICT(filename, arrangement) DO UPDATE SET last_position = excluded.last_position, + seconds_total = song_stats.seconds_total + excluded.seconds_total, last_played_at = excluded.last_played_at, updated_at = excluded.updated_at""", - (filename, int(arrangement), float(last_position)), + (filename, int(arrangement), float(last_position), float(seconds or 0)), + ) + self.conn.commit() + return self._stats_row(filename, int(arrangement)) + + def add_play_seconds(self, filename: str, arrangement: int, seconds: float) -> dict: + """Accrue wall-clock play time only (no plays/score/position change) — + the recorder's seconds-only flush for unscored plays that ran to the + song's natural end (no resume position to touch there: `song:ended` + must not overwrite Continue with the end-of-song offset).""" + with self._lock: + self.conn.execute( + """INSERT INTO song_stats (filename, arrangement, seconds_total, updated_at) + VALUES (?, ?, ?, strftime('%Y-%m-%d %H:%M:%f','now')) + ON CONFLICT(filename, arrangement) DO UPDATE SET + seconds_total = song_stats.seconds_total + excluded.seconds_total, + updated_at = excluded.updated_at""", + (filename, int(arrangement), float(seconds)), ) self.conn.commit() return self._stats_row(filename, int(arrangement)) diff --git a/lib/routers/stats.py b/lib/routers/stats.py index d6be701..5b8ed44 100644 --- a/lib/routers/stats.py +++ b/lib/routers/stats.py @@ -76,6 +76,22 @@ def api_record_stats(data: dict): last_pos = data.get("lastPlayPosition", data.get("last_position")) if isinstance(last_pos, bool): # float(False)=0.0 would otherwise store a bogus position return JSONResponse({"error": "lastPlayPosition must be a finite number"}, status_code=400) + # Optional wall-clock play time (career hours odometer). Bounded per POST: + # the recorder flushes on pause/stop/end, so a single delta beyond 6h is a + # clock artifact (suspend/sleep), not practice. + seconds = data.get("seconds") + if seconds is not None: + if isinstance(seconds, bool): + return JSONResponse({"error": "seconds must be a positive number"}, status_code=400) + try: + seconds = float(seconds) + if not math.isfinite(seconds): + raise ValueError("non-finite") + except (TypeError, ValueError, OverflowError): + return JSONResponse({"error": "seconds must be a positive number"}, status_code=400) + if not (0 < seconds <= 6 * 3600): + return JSONResponse({"error": "seconds must be between 0 and 21600"}, status_code=400) + seconds = seconds or 0.0 # A scored session needs BOTH score and accuracy. Exactly one provided is # ambiguous — don't silently fall through to the position-only branch. @@ -115,7 +131,8 @@ def api_record_stats(data: dict): except (TypeError, ValueError, OverflowError): return JSONResponse({"error": "lastPlayPosition must be a finite number"}, status_code=400) row = appstate.meta_db.record_session(filename, arrangement, score=score, - accuracy=accuracy, last_position=last_pos) + accuracy=accuracy, last_position=last_pos, + seconds=seconds) # Unified XP + streak side-effects — never let these drop the stat write. progress = None try: @@ -152,6 +169,21 @@ def api_record_stats(data: dict): log.warning("stats side-effects (progression) failed", exc_info=True) return {"stats": row, "progress": progress, "progression": progression_summary} + # Seconds-only accrual: an unscored play that ran to the song's natural + # end has play time to bank but no resume position to touch (song:ended + # must not overwrite Continue with the end-of-song offset). Still counts + # as playing today for the streak below. + if last_pos is None and seconds: + row = appstate.meta_db.add_play_seconds(filename, arrangement, seconds) + progress = None + try: + from datetime import date + appstate.meta_db.record_active_day(date.today().isoformat()) + progress = appstate.meta_db.get_progress() + except Exception: + log.warning("stats side-effects (streak) failed", exc_info=True) + return {"stats": row, "progress": progress} + # Position-only touch. if last_pos is None: return JSONResponse( @@ -162,7 +194,7 @@ def api_record_stats(data: dict): pos = float(last_pos) if not math.isfinite(pos): raise ValueError("non-finite") - row = appstate.meta_db.touch_position(filename, arrangement, pos) + row = appstate.meta_db.touch_position(filename, arrangement, pos, seconds=seconds) except (TypeError, ValueError, OverflowError): return JSONResponse({"error": "lastPlayPosition must be a finite number"}, status_code=400) # A resume session still counts as playing today: advance the streak (no XP — diff --git a/plugins/career/assets/career.css b/plugins/career/assets/career.css index 7859dea..631269f 100644 --- a/plugins/career/assets/career.css +++ b/plugins/career/assets/career.css @@ -466,3 +466,12 @@ text-shadow: 0 2px 8px rgba(0, 0, 0, 0.8); } .pp-ceremony-sub { font-size: 0.8rem; color: #d1d5db; text-shadow: 0 1px 4px rgba(0, 0, 0, 0.8); } + +/* Hours odometer (Stage 5 post-cap — a true fact, never a meter) */ +.pp-hours { + font-size: 0.72rem; + letter-spacing: 0.08em; + color: #8a7a5e; + margin-top: 0.75rem; + font-variant-numeric: tabular-nums; +} diff --git a/plugins/career/routes.py b/plugins/career/routes.py index 920b708..26ba518 100644 --- a/plugins/career/routes.py +++ b/plugins/career/routes.py @@ -203,21 +203,24 @@ def _instrument_of(arrangements, arrangement): def _played_by_instrument_genre(): - """(instrument, genre_key) → {filename: stub dict}. Best accuracy per - (instrument, song); the JOIN keeps the same dead-song filter as _stars().""" + """((instrument, genre_key) → {filename: stub dict}, + (instrument, genre_key) → total played seconds). + Best accuracy per (instrument, song); seconds sum across every + arrangement row; the JOIN keeps the same dead-song filter as _stars().""" db = _state["meta_db"] if db is None: - return {} + return {}, {} thresholds = _state["content"]["star_accuracy_thresholds"] rows = db.conn.execute( "SELECT s.filename, s.arrangement, s.best_accuracy, s.last_played_at, " - " songs.title, songs.artist, songs.arrangements, " + " s.seconds_total, songs.title, songs.artist, songs.arrangements, " f" {_genre_expr(db)} " "FROM song_stats s JOIN songs ON songs.filename = s.filename" ).fetchall() arrs_cache = {} out = {} - for filename, arrangement, acc, played_at, title, artist, arrs_json, genre in rows: + seconds = {} + for filename, arrangement, acc, played_at, secs, title, artist, arrs_json, genre in rows: gkey = _genre_key(genre) if not gkey: continue @@ -227,10 +230,12 @@ def _played_by_instrument_genre(): except (TypeError, ValueError): arrs_cache[filename] = None instrument = _instrument_of(arrs_cache[filename], arrangement) + key = (instrument, gkey) + seconds[key] = seconds.get(key, 0.0) + (secs or 0.0) acc = acc or 0.0 - stub = out.setdefault((instrument, gkey), {}).get(filename) + stub = out.setdefault(key, {}).get(filename) if stub is None: - out[(instrument, gkey)][filename] = { + out[key][filename] = { "filename": filename, "title": title or filename, "artist": artist or "", @@ -245,7 +250,7 @@ def _played_by_instrument_genre(): acc = stub["best_accuracy"] stub["best_accuracy"] = round(acc, 4) stub["stars"] = sum(1 for t in thresholds if acc >= t) - return out + return out, seconds def _library_genres(): @@ -307,7 +312,7 @@ def _passports_view(): cfg = _state["passports_content"] graded = set(cfg.get("graded_instruments") or []) st = _career_state() - played = _played_by_instrument_genre() + played, played_seconds = _played_by_instrument_genre() received_at, by_node = _drill_by_node() instruments = {} for inst in cfg.get("instruments") or []: @@ -345,6 +350,9 @@ def _passports_view(): "graded": is_graded, "songs": songs, "qualifying_count": qualifying, + # Honest hours odometer (Stage 5 post-cap): a true fact that + # only grows — never a target, never a meter. + "seconds_total": round(played_seconds.get((inst, gkey), 0.0), 1), "drills": {"required": required, "cleared": cleared}, "badge": badge, }) diff --git a/plugins/career/screen.js b/plugins/career/screen.js index 0dd8423..b4242b9 100644 --- a/plugins/career/screen.js +++ b/plugins/career/screen.js @@ -466,17 +466,27 @@ } } + // Honest hours odometer (Stage 5 post-cap). Below a minute of history + // there is nothing meaningful to show. + function fmtHours(seconds) { + const s = Number(seconds) || 0; + if (s < 60) return ''; + if (s < 3600) return `${Math.round(s / 60)} min`; + return `${(s / 3600).toFixed(1).replace(/\.0$/, '')} h`; + } + function ppCoverHTML(inst, p) { const rot = ppJitter(inst + p.genre_key, 1.6).toFixed(2); const stamp = p.badge === 'earned' ? `BRONZE` : ''; const stubs = p.qualifying_count === 1 ? '1 stub' : `${p.qualifying_count} stubs`; + const hours = fmtHours(p.seconds_total); return ``; } @@ -568,6 +578,9 @@
${need === 1 ? `One more ${starGl} song mints this stamp.` : `${need} more ${starGl} songs mint this stamp.`}
`; } + const hours = fmtHours(p.seconds_total); + const odometer = hours + ? `
${hours} in ${esc(p.genre)}
` : ''; let drills = ''; const reqNodes = (p.drills || {}).required || []; if (reqNodes.length) { @@ -589,7 +602,7 @@
${esc(p.genre)} — ${esc(ppLabel(inst))}
- ${badgeArea}${drills} + ${badgeArea}${odometer}${drills}
Ticket stubs
@@ -792,6 +805,7 @@ // the badge-diff logic; nothing here touches the DOM. window.__careerPassportTest = { ppKey, ppJitter, ppLabel, detectNewBadges, seenBadges, markBadgeSeen, + fmtHours, }; if (document.readyState === 'loading') { diff --git a/plugins/career/tests/passports.test.js b/plugins/career/tests/passports.test.js index 94c3fd8..108f3f2 100644 --- a/plugins/career/tests/passports.test.js +++ b/plugins/career/tests/passports.test.js @@ -126,3 +126,15 @@ test('seenBadges tolerates corrupt stored values', () => { assert.equal(w.notifications.length, 1, `stored ${bad}`); } }); + +test('fmtHours: silent under a minute, minutes under an hour, tenths after', () => { + const { fmtHours } = load().__careerPassportTest; + assert.equal(fmtHours(0), ''); + assert.equal(fmtHours(59), ''); + assert.equal(fmtHours(60), '1 min'); + assert.equal(fmtHours(1800), '30 min'); + assert.equal(fmtHours(3600), '1 h'); + assert.equal(fmtHours(51120), '14.2 h'); + assert.equal(fmtHours(null), ''); + assert.equal(fmtHours('junk'), ''); +}); diff --git a/static/v3/stats-recorder.js b/static/v3/stats-recorder.js index 879fdf0..4dedd4b 100644 --- a/static/v3/stats-recorder.js +++ b/static/v3/stats-recorder.js @@ -27,7 +27,60 @@ let cur = null; // active session let recordedThisSession = false; + // Wall-clock play time (career hours odometer). Accrued across + // play/resume ↔ pause/stop/ended spans — wall time, NOT song position: + // position deltas double-count A-B loops and mis-read seeks. + let playingSince = 0; // performance.now() at span start, 0 while not playing + let accruedSeconds = 0; // played time not yet sent + // Failed seconds keep their song identity — restoring them into the + // global accumulator would let the NEXT song claim them after a session + // switch. Bounded; oldest dropped beyond the cap (honest loss beats + // misattribution). + let pendingSeconds = []; // [{filename, arrangement, seconds}] awaiting retry + + function queuePendingSeconds(filename, arrangement, seconds) { + pendingSeconds.push({ filename, arrangement, seconds }); + if (pendingSeconds.length > 20) pendingSeconds.shift(); + } + + function retryPendingSeconds() { + if (!pendingSeconds.length) return; + const batch = pendingSeconds; + pendingSeconds = []; + for (const body of batch) { + post(body).then((r) => { if (r == null) queuePendingSeconds(body.filename, body.arrangement, body.seconds); }); + } + } + + function clockStart() { if (!playingSince) playingSince = performance.now(); } + function clockStop() { + if (!playingSince) return; + const delta = (performance.now() - playingSince) / 1000; + playingSince = 0; + // A single unbroken span beyond 2h of wall clock is a suspend/sleep + // artifact, not practice — clamp it. + if (Number.isFinite(delta) && delta > 0) accruedSeconds += Math.min(delta, 7200); + } + // Take whatever has accrued (closing any open span) for sending; the + // caller restores it if the POST fails so the time isn't lost. + function takeSeconds() { + clockStop(); + const s = Math.round(accruedSeconds); + accruedSeconds = 0; + return s > 0 ? s : 0; + } + // Unsent seconds belong to the outgoing song/arrangement — flush before + // a session reset would re-attribute them. + function flushSeconds() { + const s = takeSeconds(); + if (!s) return; + if (!cur || !cur.filename) return; // no session to attribute to — drop + const body = { filename: cur.filename, arrangement: cur.arrangement, seconds: s }; + post(body).then((r) => { if (r == null) queuePendingSeconds(body.filename, body.arrangement, s); }); + } + function reset(filename, arrangement) { + flushSeconds(); cur = { filename: filename || null, arrangement: Number.isFinite(arrangement) ? arrangement : 0, @@ -84,6 +137,7 @@ if (!cur || !cur.filename || recordedThisSession) return; if (!cur.scored || (cur.hits + cur.misses) <= 0) return; // no real scoring this session recordedThisSession = true; + const seconds = takeSeconds(); const body = { filename: cur.filename, arrangement: cur.arrangement, @@ -94,7 +148,9 @@ bestStreak: cur.bestStreak, lastPlayPosition: Number.isFinite(position) ? position : cur.lastTime, }; + if (seconds) body.seconds = seconds; post(body).then(async (response) => { + if (response == null && seconds) queuePendingSeconds(body.filename, body.arrangement, seconds); await notifyProgression(response, body, !!natural); // Refresh the profile badge AFTER the progression state moved so // the rank/dB it renders are post-award values. @@ -112,7 +168,10 @@ // Allow 0: restarting a song and stopping at the very beginning must be // able to clear a stale Continue offset. Only negatives are invalid. if (!Number.isFinite(position) || position < 0) return; - post({ filename: cur.filename, arrangement: cur.arrangement, lastPlayPosition: position }); + const seconds = takeSeconds(); + const body = { filename: cur.filename, arrangement: cur.arrangement, lastPlayPosition: position }; + if (seconds) body.seconds = seconds; + post(body).then((r) => { if (r == null && seconds) queuePendingSeconds(body.filename, body.arrangement, seconds); }); } // ── Session lifecycle ─────────────────────────────────────────────────-- @@ -164,13 +223,28 @@ }); }); + // ── Play-time clock ───────────────────────────────────────────────────-- + sm.on('song:play', () => { clockStart(); retryPendingSeconds(); }); + sm.on('song:resume', clockStart); + // ── Finalize / resume-position ────────────────────────────────────────-- - sm.on('song:ended', (e) => finalizeScored(e && e.detail && e.detail.time, true)); - sm.on('song:pause', (e) => touchPosition(e && e.detail && e.detail.time)); + sm.on('song:ended', (e) => { + clockStop(); + finalizeScored(e && e.detail && e.detail.time, true); + // Unscored natural end: no finalize POST and no position touch + // (Continue must not point at the end of the song) — bank the play + // time on its own. + flushSeconds(); + }); + sm.on('song:pause', (e) => { + clockStop(); + touchPosition(e && e.detail && e.detail.time); + }); sm.on('song:stop', (e) => { // Record the scored session if it wasn't already (e.g. user closed the // player before the track ended), then persist the resume position. // Not a natural end — no calibration-retry prompt for deliberate quits. + clockStop(); const t = e && e.detail && e.detail.time; finalizeScored(t, false); touchPosition(t); diff --git a/tests/plugins/career/conftest.py b/tests/plugins/career/conftest.py index a998e94..205eb81 100644 --- a/tests/plugins/career/conftest.py +++ b/tests/plugins/career/conftest.py @@ -26,7 +26,8 @@ class FakeMetaDb: self.conn.execute( """CREATE TABLE song_stats ( filename TEXT, arrangement TEXT, best_accuracy REAL, - last_played_at TEXT + last_played_at TEXT, + seconds_total REAL NOT NULL DEFAULT 0 )""" ) self.conn.execute( @@ -37,9 +38,10 @@ class FakeMetaDb: ) def add(self, filename, arrangement, best_accuracy, in_library=True, - genre="", arrangements=None, last_played_at=None): - self.conn.execute("INSERT INTO song_stats VALUES (?, ?, ?, ?)", - (filename, arrangement, best_accuracy, last_played_at)) + genre="", arrangements=None, last_played_at=None, seconds_total=0): + self.conn.execute("INSERT INTO song_stats VALUES (?, ?, ?, ?, ?)", + (filename, arrangement, best_accuracy, last_played_at, + seconds_total)) if in_library: self.conn.execute( "INSERT INTO songs SELECT ?, ?, ?, ?, ? WHERE NOT EXISTS " diff --git a/tests/plugins/career/test_passports.py b/tests/plugins/career/test_passports.py index 61f2829..c58312e 100644 --- a/tests/plugins/career/test_passports.py +++ b/tests/plugins/career/test_passports.py @@ -143,3 +143,15 @@ def test_drill_state_validation(client): huge = {"byNode": {"pad": "x" * (300 * 1024)}} assert client.post("/api/plugins/career/drill-state", json=huge).status_code == 413 + + +def test_hours_odometer_sums_seconds_per_instrument_and_genre(client, meta_db): + both = [{"type": "lead", "name": "Lead"}, {"type": "bass", "name": "Bass"}] + # Two lead arrangements' time sums; the bass row stays on the bass passport. + meta_db.add("a.feedpak", 0, 0.8, genre="Blues", arrangements=both, seconds_total=600) + meta_db.add("b.feedpak", 0, 0.8, genre="Blues", arrangements=both, seconds_total=300) + meta_db.add("b.feedpak", 1, 0.9, genre="Blues", arrangements=both, seconds_total=1200) + _open(client, "guitar") + _open(client, "bass") + assert _passport(client, "guitar")["seconds_total"] == 900 + assert _passport(client, "bass")["seconds_total"] == 1200 diff --git a/tests/test_song_stats_api.py b/tests/test_song_stats_api.py index 9e138ba..cb2ebf9 100644 --- a/tests/test_song_stats_api.py +++ b/tests/test_song_stats_api.py @@ -452,3 +452,52 @@ def test_award_xp_negative_reversal_clamps_at_zero(server): db.award_xp(50, "minigames") assert db.award_xp(-50, "minigames") == 0 # exact reversal assert db.award_xp(-999, "minigames") == 0 # over-reverse clamps at 0 + + +# ── Wall-clock play-time accrual (career hours odometer) ───────────────────── + +def test_seconds_accrue_on_scored_and_position_posts(client): + r = client.post("/api/stats", json={"filename": "s.archive", "score": 400, + "accuracy": 0.6, "seconds": 120}) + assert r.status_code == 200 + assert r.json()["stats"]["seconds_total"] == pytest.approx(120) + # Position-only touch accrues too. + r2 = client.post("/api/stats", json={"filename": "s.archive", + "lastPlayPosition": 12.5, "seconds": 30}) + assert r2.json()["stats"]["seconds_total"] == pytest.approx(150) + # A POST without seconds leaves the total alone. + r3 = client.post("/api/stats", json={"filename": "s.archive", "lastPlayPosition": 20.0}) + assert r3.json()["stats"]["seconds_total"] == pytest.approx(150) + + +def test_seconds_only_post_accrues_without_touching_position(client): + client.post("/api/stats", json={"filename": "s.archive", "lastPlayPosition": 42.0}) + r = client.post("/api/stats", json={"filename": "s.archive", "seconds": 90}) + assert r.status_code == 200 + row = r.json()["stats"] + assert row["seconds_total"] == pytest.approx(90) + # No plays counted, resume position untouched (song:ended must not + # overwrite Continue with the end-of-song offset). + assert row["plays"] == 0 + assert row["last_position"] == pytest.approx(42.0) + # Still counts as playing today for the streak. + assert r.json()["progress"]["current_streak"] == 1 + + +@pytest.mark.parametrize("bad", [True, "soon", -5, 0, 6 * 3600 + 1]) +def test_seconds_validation_rejects_junk(client, bad): + r = client.post("/api/stats", json={"filename": "s.archive", + "lastPlayPosition": 1.0, "seconds": bad}) + assert r.status_code == 400 + + +@pytest.mark.parametrize("token", ["NaN", "Infinity"]) +def test_seconds_validation_rejects_nonfinite(client, token): + # json= cannot serialize non-finite floats; python's json.loads (and thus + # the server's body parse) accepts the bare tokens, so send raw. + r = client.post( + "/api/stats", + content=f'{{"filename": "s.archive", "lastPlayPosition": 1.0, "seconds": {token}}}', + headers={"Content-Type": "application/json"}, + ) + assert r.status_code == 400