feedBack/tests/test_library_providers.py
Bret Mogilefsky af2949677a
rename: slopsmith → feedBack, byron → got-feedBack (#537)
* Update GitHub repo references from feedback* to feedBack*

* rename: slopsmith -> feedBack, byron -> got-feedBack

Renames across the entire codebase:
- slopsmith/Slopsmith/SLOPSMITH/SlopSmith -> feedBack/FeedBack/FEEDBACK/FeedBack
- byron/Byron/Byrongamatos -> got-feedBack/got-feedBack/got-feedBack
- /home/byron/ -> /opt/got-feedBack/
- byron@ougsoft.com -> hi@got-feedBack.org
- github.com/byrongamatos/ -> github.com/got-feedback/
- com.byron. -> com.got-feedback.
- SLOPSMITH_ env vars -> FEEDBACK_ with backward-compat fallback
- Protocol/storage strings migrated with read-old/write-new pattern
- window.slopsmith JS API -> window.feedBack (canonical) + backward-compat alias

Refs: #rename-slopsmith

* rename: complete regen against current main + fix backward-compat alias

Regenerated the slopsmith->feedBack / byron->got-feedBack rename on top of
current main (3 commits had landed since the branch: #572/#554/#574),
resolving the four content conflicts in favour of main's newer content
(autoplay/auto-exit, accuracy-badge, Virtuoso re-home, feedpak badge).

Completion fixes on top of the mechanical rename:
- Re-apply rename to post-branch content the original rename never saw:
  window.slopsmith(.Tour) consumers in lessons.js / notifications.js /
  onboarding-tour.js, and the matching JS + python tests (autoplay_exit,
  progression_*, test_feedpak_extension FEEDBACK_* env vars). The test env
  vars now match server.py (which reads FEEDBACK_SYNC_STARTUP /
  FEEDBACK_SKIP_STARTUP_TASKS), so the sync-startup test exercises the real
  path again.
- Restore the window.slopsmith backward-compat alias dropped during conflict
  resolution, and move the bus aliases to AFTER the _feedBackExisting merge
  block so they reference the fully-assembled object (also fixes the
  loop_api.test.js API-surface regex, which the original PR latently broke).
- Drop the stray empty data/web_library.db (runtime DB lives in CONFIG_DIR)
  and gitignore it.
- Fix stale tone-source test: feed[dB]ack -> fee[dB]ack to match shipped
  source labels.

Verified locally (org CI billing-blocked): JS 819/819 pass; pytest 1669
passed / 1683 collected with 0 import errors; zero residual slopsmith/byron
except the two intentional window.slopsmith aliases.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* rename: implement advertised backward-compat + prune dead community plugins

Address gaps where PR #537's "Backward compatibility" section was advertised
but not implemented, and clean up the community plugin list.

Env vars (FEEDBACK_* canonical, legacy SLOPSMITH_* honoured):
- New lib/env_compat.py (getenv_compat / env_flag_compat) + tests. server.py
  (_env_flag + all FEEDBACK_* reads), diagnostics_hardware, gp2midi and
  tailwind_rebuild now resolve the legacy alias, so existing SLOPSMITH_UI /
  SLOPSMITH_PLUGINS_DIR / etc. deployments keep working.
- Fix the rename collapsing plugins/__init__.py and minigames/routes.py from
  `FEEDBACK_PLUGINS_DIR or SLOPSMITH_PLUGINS_DIR` into a redundant
  `FEEDBACK_ or FEEDBACK_` (the fallback was silently lost).

Storage (app.js update-channel):
- Read feedBack-update-channel, fall back to legacy slopsmith-update-channel,
  and clear the legacy key on write — so a user's update-channel preference
  survives the rename instead of resetting to "stable".

Community plugin list (README): the rename rewrote third-party repo URLs we
don't own. Probed every one; their owners never renamed, so:
- Restore the 13 live community plugins to their real slopsmith-* names.
- Prune 6 that are 404 to the public (topkoa splitscreen/stems, OmikronApex
  tuner, Jafz2001 nam-rig-builder, DeathlySin song-preview, Erikcb91 shuffle).
- Fix a pre-existing Guitar Theory clone-command typo (nam-tone -> guitar-theory).

Verified: env_compat 7/7, JS 819/819, pytest 1690 collected / 0 import errors,
rename-sensitive + startup suites green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: byrongamatos <xasiklas@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 11:03:01 +02:00

300 lines
11 KiB
Python

import importlib
import sys
import pytest
from fastapi.responses import Response
from fastapi.testclient import TestClient
@pytest.fixture()
def server_mod(tmp_path, monkeypatch):
monkeypatch.setenv("CONFIG_DIR", str(tmp_path))
monkeypatch.setenv("FEEDBACK_SKIP_STARTUP_TASKS", "1")
sys.modules.pop("server", None)
mod = importlib.import_module("server")
yield mod
conn = getattr(getattr(mod, "meta_db", None), "conn", None)
if conn is not None:
conn.close()
@pytest.fixture()
def client(server_mod):
c = TestClient(server_mod.app)
try:
yield c
finally:
c.close()
def _put(server_mod, filename="local.archive", title="Local Song", artist="Local Artist"):
server_mod.meta_db.put(filename, 1.0, 1, {
"title": title,
"artist": artist,
"album": "Local Album",
"year": "2026",
"duration": 180.0,
"tuning": "E Standard",
"arrangements": [{"index": 0, "name": "Lead", "notes": 1}],
"has_lyrics": True,
"format": "archive",
"stem_count": 0,
"stem_ids": [],
"tuning_name": "E Standard",
"tuning_sort_key": 0,
})
class FakeLibraryProvider:
id = "remote:frodo"
label = "Frodo's Library"
kind = "remote"
capabilities = ("library.read", "art.read", "song.sync")
def __init__(self):
self.page_kwargs = None
self.artist_kwargs = None
self.stats_kwargs = None
self.art_song_id = None
self.sync_song_id = None
def query_page(self, **kwargs):
self.page_kwargs = kwargs
return ([{
"filename": "remote-song-id",
"title": "Remote Song",
"artist": "Remote Artist",
"album": "Remote Album",
"arrangements": [],
"format": "archive",
}], 1)
def query_artists(self, **kwargs):
self.artist_kwargs = kwargs
return ([{
"name": "Remote Artist",
"album_count": 1,
"song_count": 1,
"albums": [{"name": "Remote Album", "songs": []}],
}], 1)
def query_stats(self, **kwargs):
self.stats_kwargs = kwargs
return {"total_songs": 1, "total_artists": 1, "letters": {"R": 1}}
def tuning_names(self):
return {"tunings": [{"name": "E Standard", "sort_key": 0, "count": 1}]}
def get_art(self, song_id: str):
self.art_song_id = song_id
return Response(content=b"fake-art", media_type="image/png")
def sync_song(self, song_id: str):
self.sync_song_id = song_id
return {"ok": True, "filename": "synced.archive", "song_id": song_id}
class ReadOnlyLibraryProvider:
id = "remote:readonly"
label = "Readonly Library"
kind = "remote"
capabilities = ("library.read",)
def query_page(self, **kwargs):
return ([], 0)
def query_artists(self, **kwargs):
return ([], 0)
def query_stats(self, **kwargs):
return {"total_songs": 0, "total_artists": 0, "letters": {}}
def tuning_names(self):
return {"tunings": []}
def get_art(self, song_id: str):
raise AssertionError("provider without art.read capability should not be dispatched")
def sync_song(self, song_id: str):
raise AssertionError("provider without song.sync capability should not be dispatched")
class NonBrowsableLibraryProvider:
id = "remote:actions-only"
label = "Actions Only"
kind = "remote"
capabilities = ("art.read", "song.sync")
def query_page(self, **kwargs):
raise AssertionError("provider without library.read capability should not be dispatched")
def query_artists(self, **kwargs):
raise AssertionError("provider without library.read capability should not be dispatched")
def query_stats(self, **kwargs):
raise AssertionError("provider without library.read capability should not be dispatched")
def tuning_names(self):
raise AssertionError("provider without library.read capability should not be dispatched")
def get_art(self, song_id: str):
raise AssertionError("get_art should not be dispatched in this test")
def sync_song(self, song_id: str):
raise AssertionError("sync_song should not be dispatched in this test")
def test_local_provider_is_default_library_provider(server_mod, client):
_put(server_mod)
providers = client.get("/api/library/providers").json()["providers"]
local = next(provider for provider in providers if provider["id"] == "local")
assert local["label"] == "My Library"
assert local["kind"] == "local"
assert local["default"] is True
assert "library.read" in local["capabilities"]
default_payload = client.get("/api/library").json()
explicit_payload = client.get("/api/library", params={"provider": "local"}).json()
assert default_payload == explicit_payload
assert default_payload["songs"][0]["filename"] == "local.archive"
def test_registered_provider_handles_library_endpoints(server_mod, client):
provider = FakeLibraryProvider()
server_mod.register_library_provider(provider)
providers = client.get("/api/library/providers").json()["providers"]
remote = next(item for item in providers if item["id"] == "remote:frodo")
assert remote["label"] == "Frodo's Library"
assert remote["kind"] == "remote"
assert remote["default"] is False
assert remote["capabilities"] == ["art.read", "library.read", "song.sync"]
songs = client.get("/api/library", params={
"provider": "remote:frodo",
"q": "needle",
"page": "2",
"size": "12",
"sort": "title-desc",
"dir": "desc",
"format": "archive",
"favorites": "1",
"arrangements_has": "Lead,Rhythm",
"has_lyrics": "1",
"tunings": "E Standard,Drop D",
}).json()
assert songs["total"] == 1
assert songs["songs"][0]["title"] == "Remote Song"
assert provider.page_kwargs["q"] == "needle"
assert provider.page_kwargs["page"] == 2
assert provider.page_kwargs["size"] == 12
assert provider.page_kwargs["sort"] == "title-desc"
assert provider.page_kwargs["direction"] == "desc"
assert provider.page_kwargs["favorites_only"] is True
assert provider.page_kwargs["format_filter"] == "archive"
assert provider.page_kwargs["arrangements_has"] == ["Lead", "Rhythm"]
assert provider.page_kwargs["has_lyrics"] == 1
assert provider.page_kwargs["tunings"] == ["E Standard", "Drop D"]
artists = client.get("/api/library/artists", params={
"provider": "remote:frodo",
"letter": "R",
"page": "1",
"size": "5",
}).json()
assert artists["total_artists"] == 1
assert provider.artist_kwargs["letter"] == "R"
assert provider.artist_kwargs["page"] == 1
assert provider.artist_kwargs["size"] == 5
assert "sort" not in provider.artist_kwargs
stats = client.get("/api/library/stats", params={"provider": "remote:frodo"}).json()
assert stats["letters"] == {"R": 1}
assert "page" not in provider.stats_kwargs
assert "size" not in provider.stats_kwargs
assert "sort" not in provider.stats_kwargs
tunings = client.get("/api/library/tuning-names", params={"provider": "remote:frodo"}).json()
assert tunings["tunings"][0]["name"] == "E Standard"
art = client.get("/api/library/providers/remote:frodo/songs/remote-song-id/art")
assert art.status_code == 200
assert art.content == b"fake-art"
assert art.headers["content-type"].startswith("image/png")
assert provider.art_song_id == "remote-song-id"
synced = client.post("/api/library/providers/remote:frodo/songs/remote-song-id/sync").json()
assert synced == {"ok": True, "filename": "synced.archive", "song_id": "remote-song-id"}
assert provider.sync_song_id == "remote-song-id"
def test_unknown_library_provider_returns_404(server_mod, client):
response = client.get("/api/library", params={"provider": "missing"})
assert response.status_code == 404
assert "Unknown library provider" in response.json()["detail"]
def test_provider_art_and_sync_report_unsupported_when_missing(server_mod, client):
server_mod.register_library_provider(ReadOnlyLibraryProvider())
art = client.get("/api/library/providers/remote:readonly/songs/remote-song-id/art")
assert art.status_code == 501
assert "does not declare capability 'art.read'" in art.json()["detail"]
synced = client.post("/api/library/providers/remote:readonly/songs/remote-song-id/sync")
assert synced.status_code == 501
assert "does not declare capability 'song.sync'" in synced.json()["detail"]
def test_library_read_endpoints_require_library_read_capability(server_mod, client):
server_mod.register_library_provider(NonBrowsableLibraryProvider())
responses = [
client.get("/api/library", params={"provider": "remote:actions-only"}),
client.get("/api/library/artists", params={"provider": "remote:actions-only"}),
client.get("/api/library/stats", params={"provider": "remote:actions-only"}),
client.get("/api/library/tuning-names", params={"provider": "remote:actions-only"}),
]
for response in responses:
assert response.status_code == 501
assert "does not declare capability 'library.read'" in response.json()["detail"]
def test_local_library_provider_cannot_be_replaced(server_mod):
provider = FakeLibraryProvider()
provider.id = "local"
provider.label = "Not Actually Local"
try:
server_mod.register_library_provider(provider, replace=True)
except ValueError as exc:
assert "local library provider cannot be replaced" in str(exc)
else:
raise AssertionError("expected replacing the local provider to fail")
def test_library_provider_registration_is_available_to_plugins(tmp_path, monkeypatch):
monkeypatch.setenv("CONFIG_DIR", str(tmp_path))
monkeypatch.setenv("FEEDBACK_SYNC_STARTUP", "1")
sys.modules.pop("server", None)
server = importlib.import_module("server")
captured = {}
def _capturing_load_plugins(app, context, **kwargs):
captured.update(context)
monkeypatch.setattr(server, "load_plugins", _capturing_load_plugins)
monkeypatch.setattr(server, "startup_scan", lambda: None)
conn = getattr(getattr(server, "meta_db", None), "conn", None)
try:
with TestClient(server.app):
assert captured["library_providers"] is server.library_providers
assert captured["register_library_provider"] is server.register_library_provider
assert captured["unregister_library_provider"] is server.unregister_library_provider
finally:
if conn is not None:
conn.close()