mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-08-11 03:09:57 +00:00
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 <noreply@anthropic.com>
94 lines
3.2 KiB
Python
94 lines
3.2 KiB
Python
import json
|
|
import sqlite3
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent / 'plugins' / 'career'))
|
|
|
|
import pytest
|
|
from fastapi import FastAPI
|
|
from fastapi.testclient import TestClient
|
|
|
|
# Drop a sibling 'routes' cached by another plugin's tests (bare-name collision).
|
|
sys.modules.pop('routes', None)
|
|
import routes as career_routes
|
|
|
|
|
|
class FakeMetaDb:
|
|
"""song_stats/songs stand-in for MetadataDB (the plugin reads nothing else).
|
|
|
|
The real song_stats.arrangement is an INTEGER index into the song's
|
|
arrangements JSON; the legacy star tests pass strings ("guitar"), which
|
|
the passport code treats as index-less → instrument defaults to guitar."""
|
|
|
|
def __init__(self):
|
|
self.conn = sqlite3.connect(":memory:", check_same_thread=False)
|
|
self.conn.execute(
|
|
"""CREATE TABLE song_stats (
|
|
filename TEXT, arrangement TEXT, best_accuracy REAL,
|
|
last_played_at TEXT,
|
|
seconds_total REAL NOT NULL DEFAULT 0
|
|
)"""
|
|
)
|
|
self.conn.execute(
|
|
"""CREATE TABLE songs (
|
|
filename TEXT, title TEXT, artist TEXT,
|
|
genre TEXT DEFAULT '', arrangements TEXT
|
|
)"""
|
|
)
|
|
|
|
def add(self, filename, arrangement, best_accuracy, in_library=True,
|
|
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 "
|
|
"(SELECT 1 FROM songs WHERE filename = ?)",
|
|
(filename, filename.replace(".feedpak", "").title(), "Test Artist",
|
|
genre,
|
|
json.dumps(arrangements) if arrangements is not None else None,
|
|
filename))
|
|
self.conn.commit()
|
|
|
|
def add_song_only(self, filename, genre=""):
|
|
"""A library song with no plays — feeds the genre (brochure) list."""
|
|
self.conn.execute("INSERT INTO songs VALUES (?, ?, ?, ?, ?)",
|
|
(filename, filename, "Test Artist", genre, None))
|
|
self.conn.commit()
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _bind_career_routes():
|
|
"""Keep sys.modules['routes'] pointing at THIS plugin's routes for these tests."""
|
|
prev = sys.modules.get('routes')
|
|
sys.modules['routes'] = career_routes
|
|
try:
|
|
yield
|
|
finally:
|
|
if prev is not None:
|
|
sys.modules['routes'] = prev
|
|
else:
|
|
sys.modules.pop('routes', None)
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _reset_state():
|
|
# Module state outlives tests when the module stays imported — reset the
|
|
# mutable bits so ordering can't leak downloads/content between tests.
|
|
career_routes._state["downloads"] = {}
|
|
yield
|
|
|
|
|
|
@pytest.fixture
|
|
def meta_db():
|
|
return FakeMetaDb()
|
|
|
|
|
|
@pytest.fixture
|
|
def client(tmp_path, meta_db):
|
|
app = FastAPI()
|
|
career_routes.setup(app, {"config_dir": str(tmp_path), "meta_db": meta_db})
|
|
return TestClient(app)
|