Merge remote-tracking branch 'origin/main' into fix/v3-library-scroll-recycle

# Conflicts:
#	CHANGELOG.md
This commit is contained in:
byrongamatos
2026-07-05 00:19:07 +02:00
97 changed files with 5905 additions and 437 deletions
+13 -5
View File
@@ -35,10 +35,11 @@ function buildFacade() {
'return _hwcInstallFacade;',
].join('\n');
const params = [
'window', 'HWC_SLOTS', 'console',
'window', 'HWC_SLOTS', 'HWC_PRESETS', 'console',
'getHighwayStringColors', 'getHighwayDefaultSlotColors', '_hwcMergedSlotColors',
'_hwcSlotKeysForChart', '_hwcEffectiveIndexColors', '_hwcChartShape',
'applyHighwayStringColors', 'encodeHighwayColorShare', 'decodeHighwayColorShare',
'applyHighwayStringColors', 'applyHighwayStringPreset',
'encodeHighwayColorShare', 'decodeHighwayColorShare',
];
const listeners = {};
@@ -64,14 +65,19 @@ function buildFacade() {
_hwcEffectiveIndexColors: (map, sc, isBass) => ['eff', sc, isBass],
_hwcChartShape: () => ({ sc: 6, isBass: false }),
applyHighwayStringColors: (m) => { calls.push(['apply', m]); },
applyHighwayStringPreset: (id) => { calls.push(['preset', id]); return true; },
encodeHighwayColorShare: (n, m) => 'SLOPHWY2.CODE',
decodeHighwayColorShare: (c) => ({ name: 'x', colors: {} }),
};
const HWC_PRESETS = [
{ id: 'stock', label: 'Stock', colors: { lowE: '#cc0000' } },
];
const installer = new Function(...params, body)(
win, HWC_SLOTS, console,
win, HWC_SLOTS, HWC_PRESETS, console,
stubs.getHighwayStringColors, stubs.getHighwayDefaultSlotColors, stubs._hwcMergedSlotColors,
stubs._hwcSlotKeysForChart, stubs._hwcEffectiveIndexColors, stubs._hwcChartShape,
stubs.applyHighwayStringColors, stubs.encodeHighwayColorShare, stubs.decodeHighwayColorShare,
stubs.applyHighwayStringColors, stubs.applyHighwayStringPreset,
stubs.encodeHighwayColorShare, stubs.decodeHighwayColorShare,
);
installer();
return { api: win.feedBack.highwayColors, win, bus, calls, installer, stubs };
@@ -87,11 +93,13 @@ test('facade exposes the documented surface', () => {
const { api } = buildFacade();
assert.equal(api.version, 1);
for (const m of ['get', 'getDefaults', 'getResolved', 'keysForChart', 'toEffective',
'getCurrent', 'apply', 'encodeShare', 'decodeShare', 'onChange', 'offChange']) {
'getCurrent', 'apply', 'applyPreset', 'encodeShare', 'decodeShare', 'onChange', 'offChange']) {
assert.equal(typeof api[m], 'function', `highwayColors.${m} must be a function`);
}
assert.deepEqual(api.slots.map((s) => s.key),
['highE', 'B', 'G', 'D', 'A', 'lowE', 'low7', 'low8'], 'slots in display order');
// One-click presets: exposed as detached [{ id, label, colors }] copies.
assert.deepEqual(api.presets, [{ id: 'stock', label: 'Stock', colors: { lowE: '#cc0000' } }]);
});
test('facade read methods delegate to the manager', () => {
+4 -1
View File
@@ -74,7 +74,10 @@ const APP_JS = path.join(ROOT, 'static', 'app.js');
const LIBRARY_JS = path.join(ROOT, 'static', 'capabilities', 'library.js');
function source(file) {
return fs.readFileSync(file, 'utf8');
// Normalize CRLF: region() slices fixed CHARACTER windows, so on a
// Windows checkout (autocrlf) every line costs one extra char and the
// assertion target can fall outside the window.
return fs.readFileSync(file, 'utf8').replace(/\r\n/g, '\n');
}
function region(src, needle, length = 1200) {
+3 -1
View File
@@ -40,7 +40,9 @@ test('settings UI exposes tone source select with all options', () => {
assert.match(html, /value="external_hardware"/);
assert.match(html, /value="spark_control_x"/);
assert.match(html, /Live guitar tone source/);
assert.match(html, /won’t warn that no internal amp tone is loaded/);
// Apostrophe form drifted from the ’ entity to the literal ’ in a
// copy pass — accept entity, typographic, or plain apostrophe.
assert.match(html, /won(?:’|’|')t warn that no internal amp tone is loaded/);
});
test('player audio rail exposes tone source select', () => {
+1
View File
@@ -107,6 +107,7 @@ function loadFunctions(sandbox, src) {
sectionPracticeModeCalls.push({ on, opts: opts || {} });
}
function _updateSectionPracticeHighlight(ct) {}
function _updateEditRegionBtn() {}
${extractFunction(src, 'function clearLoop(')}
${extractFunction(src, 'function _syncSavedLoopSelection()')}
${extractFunction(src, 'async function setLoop(')}
+86
View File
@@ -0,0 +1,86 @@
// playQueue.start({ shuffle: true }): the queue is Fisher-Yates-shuffled ONCE
// at start. Per-slot arrangements must swap in lockstep with their files
// (albums pass arrangements aligned by index, #685), the caller's arrays must
// not be mutated, and shuffle:false / absent must preserve order. Extract the
// playQueue IIFE from app.js and drive it against a playSong stub.
'use strict';
const test = require('node:test');
const assert = require('node:assert');
const fs = require('node:fs');
const path = require('node:path');
function makeQueue() {
const src = fs.readFileSync(path.join(__dirname, '..', '..', 'static', 'app.js'), 'utf8');
const start = src.indexOf('window.feedBack.playQueue = (function () {');
assert.ok(start !== -1, 'playQueue IIFE found in app.js');
const end = src.indexOf('})();', start);
assert.ok(end !== -1, 'playQueue IIFE terminator found');
const iife = src.slice(start, end + 5);
const played = [];
const sandbox = {
window: {
feedBack: {},
playSong: (fn, arr, opts) => played.push({ fn: decodeURIComponent(fn), arr, opts }),
fbNotify: null,
},
};
// eslint-disable-next-line no-new-func
new Function('window', 'encodeURIComponent', iife)(sandbox.window, encodeURIComponent);
return { q: sandbox.window.feedBack.playQueue, played };
}
function drain(q, played) {
while (q.hasNext()) q.advance();
return played.map((p) => p.fn);
}
test('shuffle: same multiset, order from the seeded RNG, arrangements follow files', () => {
const files = ['a.sloppak', 'b.sloppak', 'c.sloppak', 'd.sloppak'];
const arrs = [0, 1, 2, 3]; // arrangement i belongs to files[i]
const origRandom = Math.random;
try {
// Deterministic RNG so the expected order is checkable.
let calls = 0;
const seq = [0.1, 0.9, 0.5];
Math.random = () => seq[calls++ % seq.length];
const { q, played } = makeQueue();
q.start(files.slice(), { arrangements: arrs.slice(), shuffle: true });
const order = drain(q, played);
assert.deepStrictEqual(order.slice().sort(), files.slice().sort()); // nothing lost/duplicated
// Each played file carries the arrangement it started with.
played.forEach((p) => {
assert.strictEqual(p.arr, arrs[files.indexOf(p.fn)]);
});
} finally {
Math.random = origRandom;
}
});
test('shuffle can change the order', () => {
const origRandom = Math.random;
try {
Math.random = () => 0; // j = 0 every swap → deterministic rotation, ≠ input order
const { q, played } = makeQueue();
q.start(['a', 'b', 'c'], { shuffle: true });
const order = drain(q, played);
assert.notDeepStrictEqual(order, ['a', 'b', 'c']);
} finally {
Math.random = origRandom;
}
});
test('no shuffle opt preserves order and caller arrays are never mutated', () => {
const files = ['a', 'b', 'c'];
const arrs = [2, 0, 1];
const { q, played } = makeQueue();
q.start(files, { arrangements: arrs });
assert.deepStrictEqual(drain(q, played), ['a', 'b', 'c']);
assert.deepStrictEqual(files, ['a', 'b', 'c']);
assert.deepStrictEqual(arrs, [2, 0, 1]);
// shuffle:true must also leave the caller's arrays alone (start slices).
const { q: q2 } = makeQueue();
q2.start(files, { arrangements: arrs, shuffle: true });
assert.deepStrictEqual(files, ['a', 'b', 'c']);
assert.deepStrictEqual(arrs, [2, 0, 1]);
});
+115
View File
@@ -0,0 +1,115 @@
// Verify loadPlugins' plugin-DOM wipe loops in static/app.js: a plugin that is
// merely ABSENT from the current /api/plugins response (transient partial
// response while the backend's plugin registry is repopulating after a
// restart) must keep its settings panel and screen DOM. Wiping it while its
// _loadedPluginScripts entry survives made the next refetch fail the
// DOM-existence check and re-evaluate the plugin's screen.js mid-session —
// which duplicated the desktop audio_engine's native signal chain. Plugins
// the response knows about but that failed hydration are still wiped, as is
// junk DOM carrying no plugin id.
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
const APP_JS = path.join(__dirname, '..', '..', 'static', 'app.js');
// Slice the wipe block out of loadPlugins by its stable landmarks: from the
// nav reset that opens it to the comment introducing the next section.
function extractWipeBlock(src) {
const start = src.indexOf("navContainer.innerHTML = '';");
assert.ok(start !== -1, 'wipe block start (nav reset) not found');
const end = src.indexOf('// Plugin settings area hosts', start);
assert.ok(end !== -1, 'wipe block end marker not found');
return src.slice(start, end);
}
function makeEl(pluginId, id) {
return {
dataset: pluginId != null ? { pluginId } : {},
id: id || (pluginId != null ? `plugin-${pluginId}` : ''),
removed: false,
remove() {
this.removed = true;
const idx = this._parent ? this._parent.indexOf(this) : -1;
if (idx >= 0) this._parent.splice(idx, 1);
},
};
}
function runWipe({ respondedIds, alreadyHydrated, settingsChildren, screens }) {
const src = fs.readFileSync(APP_JS, 'utf8');
const block = extractWipeBlock(src);
settingsChildren.forEach((el) => { el._parent = settingsChildren; });
const container = { children: settingsChildren };
const sandbox = {
navContainer: { innerHTML: 'seed' },
mobileNavContainer: { innerHTML: 'seed' },
_pluginSettingsContainers: () => [container],
respondedIds,
alreadyHydrated,
document: {
querySelectorAll: (sel) => {
assert.equal(sel, '.screen[id^="plugin-"]');
return screens.slice();
},
},
};
vm.runInNewContext(block, sandbox, { filename: 'wipe-block.js' });
return sandbox;
}
test('plugin absent from the response keeps its settings + screen DOM', () => {
const settings = makeEl('audio_engine');
const screen = makeEl('audio_engine');
runWipe({
respondedIds: new Set(), // partial response: plugin missing
alreadyHydrated: new Set(), // scan loop never saw it either
settingsChildren: [settings],
screens: [screen],
});
assert.equal(settings.removed, false, 'settings panel must survive a partial response');
assert.equal(screen.removed, false, 'screen must survive a partial response');
});
test('plugin present in the response but not hydrated is wiped', () => {
const settings = makeEl('stale_plugin');
const screen = makeEl('stale_plugin');
runWipe({
respondedIds: new Set(['stale_plugin']),
alreadyHydrated: new Set(),
settingsChildren: [settings],
screens: [screen],
});
assert.equal(settings.removed, true);
assert.equal(screen.removed, true);
});
test('hydrated plugin present in the response is preserved', () => {
const settings = makeEl('audio_engine');
const screen = makeEl('audio_engine');
runWipe({
respondedIds: new Set(['audio_engine']),
alreadyHydrated: new Set(['audio_engine']),
settingsChildren: [settings],
screens: [screen],
});
assert.equal(settings.removed, false);
assert.equal(screen.removed, false);
});
test('junk DOM without a plugin id is still removed', () => {
const junkSettings = makeEl(null);
// Screen whose id strips to '' (no dataset.pluginId, bare "plugin-" id).
const junkScreen = makeEl(null, 'plugin-');
runWipe({
respondedIds: new Set(['whatever']),
alreadyHydrated: new Set(),
settingsChildren: [junkSettings],
screens: [junkScreen],
});
assert.equal(junkSettings.removed, true);
assert.equal(junkScreen.removed, true);
});
+9 -4
View File
@@ -204,15 +204,20 @@ test('does not collide tags across two different plugins', () => {
assert.deepEqual(headLinks.map((l) => l.dataset.pluginId).sort(), ['a', 'b']);
});
test('reconcile removes the <link> of a plugin that vanished from /api/plugins', () => {
test('reconcile keeps the <link> of a plugin absent from a partial response', () => {
const { inject, reconcile, headLinks } = setupSandbox();
inject(plug({ id: 'a' }));
inject(plug({ id: 'b' }));
assert.equal(headLinks.length, 2);
// `a` is no longer returned (uninstalled) — its stylesheet must be dropped.
// `a` is missing from this response. That happens transiently during a
// backend restart (the plugin registry repopulates while HTTP stays up),
// so absence is NOT an uninstall signal — the still-loaded plugin must
// keep its stylesheet or it renders visible-but-unstyled until it
// reappears. Explicit removal still happens via the not-ready/unstyled
// paths (tests below).
reconcile([plug({ id: 'b' })]);
assert.equal(headLinks.length, 1);
assert.equal(headLinks[0].dataset.pluginId, 'b');
assert.equal(headLinks.length, 2);
assert.deepEqual(headLinks.map((l) => l.dataset.pluginId).sort(), ['a', 'b']);
});
test('reconcile removes the <link> of a plugin that is no longer ready', () => {
+4
View File
@@ -42,7 +42,10 @@ function loadClose(sandbox, src) {
globalThis.__seekCalls = 0;
globalThis.__playSongCalls = 0;
globalThis.__clearLoopCalls = 0;
globalThis.__queueClearCalls = 0;
globalThis.__audioCurrentTimeSets = [];
// closeCurrentSong abandons any play-queue before leaving the player.
var window = { feedBack: { playQueue: { clear() { globalThis.__queueClearCalls++; } } } };
var audio = {
_t: 42,
get currentTime() { return this._t; },
@@ -75,6 +78,7 @@ test('closeCurrentSong uses _playerOriginScreen when set', async () => {
await sandbox.__closeCurrentSong();
assert.equal(sandbox.__showScreenCalls.length, 1);
assert.equal(sandbox.__showScreenCalls[0], 'favorites');
assert.equal(sandbox.__queueClearCalls, 1, 'a real close abandons the play-queue');
assert.equal(sandbox.__restartCalls, 0);
assert.equal(sandbox.__seekCalls, 0);
assert.equal(sandbox.__playSongCalls, 0);
+11 -9
View File
@@ -31,21 +31,23 @@ test('the home is the unfiltered grid front door, local provider only', () => {
);
});
test('the shelf is recently-played, not-yet-mastered songs (per-song, deduped)', () => {
assert.match(src, /\/api\/stats\/recent\?limit=/);
// Mastery is gated on the per-SONG best (state.accuracy, what the badge
// shows), not the per-arrangement recents row, and each filename appears
// once — so no green-badged "keep practicing" card and no duplicates.
test('the shelf is the server-side practice-suggestions recommender', () => {
// The old client-side pipeline (fetch /api/stats/recent, dedupe by
// filename, gate on state.accuracy) moved server-side: the growth-edge
// recommender gates (not-mastered) + aggregates per song and picks the
// arrangement closest to mastery. The client renders its rows as-is.
assert.match(src, /\/api\/library\/practice-suggestions\?limit=/);
// A shelf card click opens the row's recommended arrangement, not the
// song's default.
assert.match(
src,
/const\s+best\s*=\s*acc\[r\.filename\][\s\S]*?best\s*>=\s*MASTERY_ACCURACY/,
'the shelf must gate on the per-song best (state.accuracy) at MASTERY_ACCURACY',
/data-arr="[\s\S]*?getAttribute\('data-arr'\)[\s\S]*?playSong\(enc\(fn\), arr === '' \? undefined : Number\(arr\)\)/,
'shelf cards must pass the recommended arrangement to playSong',
);
assert.match(src, /seen\.has\(r\.filename\)/, 'the shelf must dedupe recents by filename');
});
test('the meter + shelf fetch together and a stale render is discarded', () => {
assert.match(src, /Promise\.all\(\[[\s\S]*?library\/stats[\s\S]*?stats\/recent/,
assert.match(src, /Promise\.all\(\[[\s\S]*?library\/stats[\s\S]*?practice-suggestions/,
'the two reads must be issued together (Promise.all), not sequentially');
assert.match(src, /_homeToken[\s\S]*?_homeToken !== myToken/,
'a stale render must be superseded by a newer one via a token');
+3 -1
View File
@@ -64,7 +64,9 @@ const helpers = loadTuningHelpers();
test('v3 songs.js uses display helpers for album-art tuning badge', () => {
const src = fs.readFileSync(SONGS_JS, 'utf8');
assert.match(src, /displayTuningName\(song\.tuning_name \|\| song\.tuning\)/);
// The card renderer's row variable was renamed song → shown when grouped
// cards landed (the badge reads the representative chart); accept either.
assert.match(src, /displayTuningName\((?:song|shown)\.tuning_name \|\| (?:song|shown)\.tuning\)/);
assert.match(src, /displayTuningTargets/);
assert.match(src, /parseRawTuningOffsets/);
});
+5 -4
View File
@@ -16,8 +16,8 @@ const { createWindow, ROOT } = require('./capabilities_test_harness');
const CAPABILITIES_JS = path.join(ROOT, 'static', 'capabilities.js');
const WORKING_TUNING_JS = path.join(ROOT, 'static', 'capabilities', 'working-tuning.js');
// A /api/tunings-shaped fixture (frequencies at 440), enough to resolve names to offsets.
const TUNINGS = {
// Tuning frequency fixture at 440 Hz, enough to resolve names to offsets.
const TUNING_TABLE = {
'guitar-6': {
Standard: [82.41, 110.00, 146.83, 196.00, 246.94, 329.63],
'Drop D': [73.42, 110.00, 146.83, 196.00, 246.94, 329.63],
@@ -26,6 +26,7 @@ const TUNINGS = {
Standard: [30.87, 41.20, 55.00, 73.42, 98.00],
},
};
const API_TUNINGS = { referencePitch: 440, tunings: TUNING_TABLE };
function deferred() {
let resolve;
@@ -159,7 +160,7 @@ test('bare-instrument writes target the current selection, not a hard-coded defa
test('seed resolves a NAMED tuning to offsets via /api/tunings', async () => {
const { wt, changes } = loadWorkingTuning({
'/api/settings': { instrument: 'guitar', string_count: 6, tuning: 'Drop D', reference_pitch: 440 },
'/api/tunings': TUNINGS,
'/api/tunings': API_TUNINGS,
});
await flush();
const s = wt.get('guitar-6');
@@ -183,7 +184,7 @@ test('boot race: an explicit set() before settings resolve is not clobbered by t
const settings = deferred();
const { wt } = loadWorkingTuning({
'/api/settings': settings.promise, // held open
'/api/tunings': TUNINGS,
'/api/tunings': API_TUNINGS,
});
// A consumer writes before the seed lands.
wt.set({ offsets: [-5, -5, -5, -5, -5, -5] }, { instrument: 'guitar-6' });
+16
View File
@@ -7,6 +7,8 @@ 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 ach_routes
@@ -26,3 +28,17 @@ def client(tmp_path):
app = FastAPI()
ach_routes.setup(app, {"config_dir": str(tmp_path)})
return TestClient(app)
@pytest.fixture(autouse=True)
def _bind_ach_routes():
"""Keep sys.modules['routes'] pointing at THIS plugin's routes for these tests."""
prev = sys.modules.get('routes')
sys.modules['routes'] = ach_routes
try:
yield
finally:
if prev is not None:
sys.modules['routes'] = prev
else:
sys.modules.pop('routes', None)
+18
View File
@@ -5,6 +5,8 @@ sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent / 'plugins' /
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 tuner_routes
@@ -22,3 +24,19 @@ def client(config_dir):
"unregister_tuning_provider": lambda pid: None,
})
return TestClient(app)
@pytest.fixture(autouse=True)
def _bind_tuner_routes():
"""Keep sys.modules['routes'] pointing at THIS plugin's routes for these
tests, so a runtime `import routes` in a test body resolves correctly
regardless of which other plugin's bare-named routes ran first."""
prev = sys.modules.get('routes')
sys.modules['routes'] = tuner_routes
try:
yield
finally:
if prev is not None:
sys.modules['routes'] = prev
else:
sys.modules.pop('routes', None)
+120
View File
@@ -0,0 +1,120 @@
"""Pure-function tests for AcoustID fingerprint response parsing + config
gating. No network, no fpcalc binary — server.py owns those seams."""
import acoustid_match as a
def _resp(score=0.97, rec_id="rec-1", title="Highway to Hell", artist="AC/DC",
rg_title="Highway to Hell", rg_type="Album", secondary=None,
year=1979, duration=208.4):
return {
"status": "ok",
"results": [{
"id": "acoustid-uuid",
"score": score,
"recordings": [{
"id": rec_id,
"title": title,
"duration": duration,
"artists": [{"id": "a1", "name": artist}],
"releasegroups": [{
"id": "rg1", "title": rg_title, "type": rg_type,
"secondarytypes": secondary or [],
"releases": [{"date": {"year": year}}],
}],
}],
}],
}
def test_parse_maps_the_studio_recording():
out = a.parse_lookup_response(_resp())
assert len(out) == 1
c = out[0]
assert c["recording_id"] == "rec-1"
assert c["title"] == "Highway to Hell"
assert c["artist"] == "AC/DC"
assert c["album"] == "Highway to Hell"
assert c["year"] == "1979"
assert c["duration"] == 208
assert c["studio"] is True
assert c["source"] == "acoustid"
assert c["mb_score"] == 97 # 0.97 → 0..100 confidence band
assert c["score"] == 0.97
def test_live_release_group_is_not_studio():
out = a.parse_lookup_response(_resp(rg_type="Album", secondary=["Live"]))
assert out[0]["studio"] is False
def test_compilation_is_not_studio():
out = a.parse_lookup_response(_resp(secondary=["Compilation"]))
assert out[0]["studio"] is False
def test_prefers_studio_group_for_album_display():
resp = _resp()
# Add a comp release-group first; the studio one must win the album pick.
resp["results"][0]["recordings"][0]["releasegroups"].insert(0, {
"id": "rg0", "title": "Greatest Hits", "type": "Album",
"secondarytypes": ["Compilation"], "releases": [{"date": {"year": 2000}}],
})
c = a.parse_lookup_response(resp)[0]
assert c["album"] == "Highway to Hell"
assert c["studio"] is True
def test_earliest_studio_album_wins_over_later_one():
# Two studio "Album" groups (e.g. a later soundtrack typed Album). The
# ORIGINAL — earliest release year — must win the album pick, not whichever
# AcoustID happened to list first. (Real case: "Machine Head" over a later
# comp for "Smoke on the Water".)
resp = _resp(rg_title="Machine Head", year=1972)
resp["results"][0]["recordings"][0]["releasegroups"].insert(0, {
"id": "rg-late", "title": "Later Studio Album", "type": "Album",
"secondarytypes": [], "releases": [{"date": {"year": 1997}}],
})
c = a.parse_lookup_response(resp)[0]
assert c["album"] == "Machine Head"
assert c["year"] == "1972"
def test_year_is_earliest_release_not_a_reissue():
# A group's first-listed release is often a reissue; the year must be the
# EARLIEST across the group's releases (real case: British Steel's 1980
# original, not a 2010 reissue listed first).
resp = _resp(rg_title="British Steel", year=2010)
resp["results"][0]["recordings"][0]["releasegroups"][0]["releases"].append(
{"date": {"year": 1980}})
c = a.parse_lookup_response(resp)[0]
assert c["year"] == "1980"
def test_dedupes_recording_across_results():
resp = _resp()
resp["results"].append(dict(resp["results"][0])) # same recording again
assert len(a.parse_lookup_response(resp)) == 1
def test_non_ok_status_and_garbage_return_empty():
assert a.parse_lookup_response({"status": "error"}) == []
assert a.parse_lookup_response({}) == []
assert a.parse_lookup_response(None) == []
assert a.parse_lookup_response({"status": "ok", "results": []}) == []
def test_higher_acoustid_score_ranks_first():
resp = _resp(score=0.55, rec_id="low")
resp["results"].append(_resp(score=0.99, rec_id="high")["results"][0])
out = a.parse_lookup_response(resp)
assert out[0]["recording_id"] == "high"
def test_config_gating(monkeypatch):
monkeypatch.delenv("ACOUSTID_API_KEY", raising=False)
assert a.api_key() == ""
assert a.is_configured() is False
assert a.is_configured("explicit-key") is True
monkeypatch.setenv("ACOUSTID_API_KEY", "envkey")
assert a.api_key() == "envkey"
assert a.is_configured() is True
+1
View File
@@ -23,6 +23,7 @@ def server(tmp_path, monkeypatch, isolate_logging):
finally:
conn = getattr(getattr(srv, "meta_db", None), "conn", None)
if conn is not None:
getattr(__import__("sys").modules.get("server"), "_join_background_db_threads", lambda: None)()
conn.close()
sys.modules.pop("server", None)
+371
View File
@@ -0,0 +1,371 @@
"""Tests for the PR-C cover picker's server side: the /art/candidates
assembly (current + pack + Cover Art Archive index candidates), the
`caa_index_{id}.json` TTL-less cache around the new `_caa_release_index`
seam, the `?source=pack` art-route variant, and the redirect-following
art-by-URL fetch that lets a CAA pick apply through the existing
override lane.
Both network seams (`_caa_release_index`, `requests.get` under
`_fetch_art_url`) are faked — nothing here opens a socket, and the
offline default is itself asserted. Fixture patterns mirror
tests/test_art_layer.py.
"""
import importlib
import io as _io
import sys
import pytest
from fastapi.testclient import TestClient
from PIL import Image
@pytest.fixture()
def server(tmp_path, monkeypatch, isolate_logging):
monkeypatch.setenv("CONFIG_DIR", str(tmp_path / "config"))
dlc = tmp_path / "dlc"
dlc.mkdir()
monkeypatch.setenv("DLC_DIR", str(dlc))
monkeypatch.setenv("FEEDBACK_SKIP_STARTUP_TASKS", "1")
sys.modules.pop("server", None)
srv = importlib.import_module("server")
try:
yield srv
finally:
conn = getattr(getattr(srv, "meta_db", None), "conn", None)
if conn is not None:
getattr(__import__("sys").modules.get("server"), "_join_background_db_threads", lambda: None)()
conn.close()
sys.modules.pop("server", None)
@pytest.fixture()
def client(server):
return TestClient(server.app)
def png_bytes(color=(200, 30, 30)):
buf = _io.BytesIO()
Image.new("RGB", (4, 4), color).save(buf, "PNG")
return buf.getvalue()
def b64(data):
import base64
return base64.b64encode(data).decode()
def make_sloppak(server, name, with_cover=False, title="Song", artist="Artist"):
d = server.DLC_DIR / name
d.mkdir(parents=True)
(d / "manifest.yaml").write_text(
f"title: {title}\nartist: {artist}\nduration: 100\n"
"arrangements: []\nstems: []\n", encoding="utf-8")
if with_cover:
(d / "cover.jpg").write_bytes(png_bytes((10, 200, 10)))
server.meta_db.put(name, 0, 0, {
"title": title, "artist": artist, "album": "", "year": "",
"duration": 100, "arrangements": [{"name": "Lead", "index": 0}],
})
return d
def _match_row(server, fn, release_id="rel-1", state="matched"):
"""Seed a matched/manual enrichment row with a release id (as the P8
matcher would have written)."""
song = server.meta_db.enrichment_song_row(fn)
h = server.meta_db.enrichment_content_hash(
song["artist"], song["title"], song["album"], song["duration"])
server.meta_db.apply_enrichment_match(
fn, h, state, source="text", score=1.0,
cand={"recording_id": "rec-1", "release_id": release_id,
"title": song["title"], "artist": song["artist"]})
def _review_row(server, fn, candidates):
"""Seed a review-tier row: no canonical release of its own, releases
live only in the stored candidates JSON."""
song = server.meta_db.enrichment_song_row(fn)
h = server.meta_db.enrichment_content_hash(
song["artist"], song["title"], song["album"], song["duration"])
server.meta_db.apply_enrichment_match(
fn, h, "review", source="text", score=0.75, candidates=candidates)
def _img(img_id, *, front=False, approved=True, sizes=("500",)):
"""One CAA index image dict, with thumbnails for the given size keys."""
return {
"id": img_id,
"front": front,
"approved": approved,
"types": ["Front"] if front else ["Back"],
"image": f"https://caa.example/full/{img_id}.jpg",
"thumbnails": {s: f"https://caa.example/{img_id}-{s}.jpg" for s in sizes},
}
@pytest.fixture()
def caa_index(server, monkeypatch):
"""Fake CAA index transport + network flag on (mirrors the art-layer
`caa` fixture; this is the picker's own seam)."""
calls = []
indexes = {
"rel-1": {"images": [_img(101, front=True),
_img(102, approved=False, sizes=("250",))]},
"rel-2": {"images": [_img(201, front=True)]},
}
def fake(release_id):
calls.append(release_id)
return indexes.get(release_id) # unknown release → None (a CAA 404)
fake.calls, fake.indexes = calls, indexes
monkeypatch.setattr(server, "_caa_release_index", fake)
monkeypatch.setattr(server, "_enrich_network_enabled", lambda: True)
return fake
def _get(client, fn="a.sloppak"):
r = client.get(f"/api/song/{fn}/art/candidates")
assert r.status_code == 200
return r.json()
def _caa(body):
return [c for c in body["candidates"] if c["kind"] == "caa"]
def _current(body):
return next(c for c in body["candidates"] if c["kind"] == "current")
# ── candidate assembly ────────────────────────────────────────────────────────
def test_matched_row_lists_index_images(server, client, caa_index):
make_sloppak(server, "a.sloppak") # no pack art
_match_row(server, "a.sloppak", release_id="rel-1")
body = _get(client)
assert body["pending"] is False
cur = _current(body)
assert cur["provenance"] == "none" # nothing served yet
assert not any(c["kind"] == "pack" for c in body["candidates"])
caa = _caa(body)
assert [c["thumb_url"] for c in caa] == [
"https://caa.example/101-500.jpg", # front, 500px
"https://caa.example/102-250.jpg", # 250 fallback
]
assert caa[0]["provenance"] == "matched"
assert caa[0]["approved"] is True and caa[1]["approved"] is False
assert caa[0]["release_id"] == "rel-1"
assert caa_index.calls == ["rel-1"] # one index fetch
def test_review_row_includes_candidate_releases(server, client, caa_index):
make_sloppak(server, "a.sloppak")
_review_row(server, "a.sloppak", [
{"recording_id": "rec-1", "title": "Song", "release_id": "rel-1"},
{"recording_id": "rec-2", "title": "Song", "release_id": "rel-2"},
{"recording_id": "rec-3", "title": "Song", "release_id": "rel-1"}, # dupe
{"recording_id": "rec-4", "title": "Song"}, # no release — skipped
])
body = _get(client)
assert caa_index.calls == ["rel-1", "rel-2"] # deduped, in order
assert {c["release_id"] for c in _caa(body)} == {"rel-1", "rel-2"}
assert len(_caa(body)) == 3
def test_rejected_row_skips_caa_fetch(server, client, caa_index):
"""A row the user rejected (failed/rejected) has no accepted match, so the
picker must not spend the shared CAA budget on its stale candidates. The
Current tile still serves; the index seam is never asked."""
make_sloppak(server, "a.sloppak")
_review_row(server, "a.sloppak", [
{"recording_id": "rec-1", "title": "Song", "release_id": "rel-1"}])
assert server.meta_db.set_enrichment_rejected("a.sloppak")
body = _get(client)
assert _caa(body) == []
assert caa_index.calls == []
assert _current(body)["kind"] == "current"
def test_unmatched_instant_tiles_only(server, client, caa_index):
"""No enrichment row at all → current (+ pack when it exists), empty
caa list, and the index seam is never asked."""
make_sloppak(server, "a.sloppak", with_cover=True)
body = _get(client)
kinds = [c["kind"] for c in body["candidates"]]
assert kinds == ["current", "pack"]
assert _current(body)["provenance"] == "pack"
pack = body["candidates"][1]
assert pack["thumb_url"].endswith("?source=pack")
assert caa_index.calls == []
def test_override_provenance_is_yours(server, client, caa_index):
make_sloppak(server, "a.sloppak", with_cover=True)
assert client.post("/api/song/a.sloppak/art/upload",
json={"image": b64(png_bytes((1, 2, 3)))}).json()["ok"]
body = _get(client)
assert _current(body)["provenance"] == "yours"
# Pack original stays offered even while the override is what serves.
assert any(c["kind"] == "pack" for c in body["candidates"])
def test_offline_empty_caa_list_no_error(server, client):
"""Under the plain test env the REAL index seam refuses (offline guard);
the endpoint still answers 200 with the instant tiles and caches
nothing (a later open retries)."""
make_sloppak(server, "a.sloppak")
_match_row(server, "a.sloppak", release_id="rel-1")
body = _get(client)
assert _caa(body) == []
assert _current(body)["kind"] == "current"
assert list(server.ART_CACHE_DIR.glob("caa_index_*.json")) == []
def test_index_cached_second_call_no_refetch(server, client, caa_index):
make_sloppak(server, "a.sloppak")
_match_row(server, "a.sloppak", release_id="rel-1")
first = _get(client)
assert len(caa_index.calls) == 1
cache = server.ART_CACHE_DIR / "caa_index_rel-1.json"
assert cache.is_file() # TTL-less on-disk cache
# Even a changed upstream index is not re-asked — indexes are stable.
caa_index.indexes["rel-1"] = {"images": []}
second = _get(client)
assert len(caa_index.calls) == 1 # no refetch
assert _caa(second) == _caa(first)
def test_404_release_cached_as_empty(server, client, caa_index):
"""A coverless release (CAA 404 → seam returns None) yields no tiles and
is never re-asked either."""
make_sloppak(server, "a.sloppak")
_match_row(server, "a.sloppak", release_id="rel-missing")
assert _caa(_get(client)) == []
assert _caa(_get(client)) == []
assert caa_index.calls == ["rel-missing"]
def test_caa_candidates_capped_at_12(server, client, caa_index):
make_sloppak(server, "a.sloppak")
caa_index.indexes["rel-big"] = {
"images": [_img(300 + i, front=(i == 0)) for i in range(20)]}
_match_row(server, "a.sloppak", release_id="rel-big")
assert len(_caa(_get(client))) == server._ART_PICKER_MAX_CAA == 12
def test_demo_mode_blocks_candidates(server, client, monkeypatch):
"""Read-only, but it spends the shared CAA rate budget — blocked in demo
like enrichment search/kick."""
make_sloppak(server, "a.sloppak")
monkeypatch.setenv("FEEDBACK_DEMO_MODE", "1")
r = client.get("/api/song/a.sloppak/art/candidates")
assert r.status_code == 403
assert r.json() == {"error": "demo mode: read-only"}
def test_unknown_song_404(server, client):
assert client.get("/api/song/ghost.sloppak/art/candidates").status_code == 404
# ── traversal / injection hardening ───────────────────────────────────────────
def test_malicious_release_id_rejected_no_fetch_no_write(server, caa_index):
"""A crafted release id (path traversal) never matches _CAA_ID_RE, so it
yields no images, opens no socket, and writes no cache file — inside the
art dir or anywhere else."""
art_dir = server._enrichment_art_dir()
before = set(art_dir.glob("*"))
assert not server._CAA_ID_RE.match("../../etc/x")
assert server._caa_index_cached("../../etc/x") == []
assert caa_index.calls == [] # the seam was never asked
assert set(art_dir.glob("*")) == before # nothing written
# And nothing landed at the traversal target beside the cache dir either.
assert not (art_dir.parent / "etc").exists()
def test_candidates_route_rejects_traversal_filename(server, client, caa_index):
"""A traversal filename resolves outside DLC_DIR → _resolve_dlc_path
refuses it, the route 404s, and the CAA seam is never touched."""
for path in ("..%2F..%2Fsecret", "%2e%2e%2f%2e%2e%2fsecret", "../../secret"):
r = client.get(f"/api/song/{path}/art/candidates")
assert r.status_code == 404, path
assert caa_index.calls == []
# ── the ?source=pack serve variant ────────────────────────────────────────────
def test_pack_source_serves_pack_under_override(server, client):
"""The Pack-original tile's thumb must show the pack's own art even while
an override is what the plain route serves — and 404 when the song ships
no art of its own."""
make_sloppak(server, "a.sloppak", with_cover=True)
assert client.post("/api/song/a.sloppak/art/upload",
json={"image": b64(png_bytes((1, 2, 3)))}).json()["ok"]
assert client.get("/api/song/a.sloppak/art").headers["content-type"] == "image/png"
r = client.get("/api/song/a.sloppak/art?source=pack")
assert r.status_code == 200
assert r.headers["content-type"] == "image/jpeg" # the pack cover, not the override
make_sloppak(server, "bare.sloppak")
assert client.get("/api/song/bare.sloppak/art?source=pack").status_code == 404
# ── art-by-URL redirect handling (what makes a CAA pick applyable) ────────────
class _FakeResp:
def __init__(self, status, headers=None, chunks=()):
self.status_code = status
self.headers = headers or {}
self._chunks = chunks
def iter_content(self, _size):
return iter(self._chunks)
def __enter__(self):
return self
def __exit__(self, *a):
return False
def test_fetch_art_url_follows_redirects_validating_each_hop(server, monkeypatch):
import requests
fetched, checked = [], []
def fake_get(url, **kw):
fetched.append(url)
assert kw.get("allow_redirects") is False # hops stay manual
if "coverartarchive.example" in url:
return _FakeResp(307, {"Location": "https://archive.example/img.png"})
return _FakeResp(200, chunks=[b"IMGDATA"])
monkeypatch.setattr(requests, "get", fake_get)
monkeypatch.setattr(server, "_enrich_network_enabled", lambda: True)
monkeypatch.setattr(server, "_url_host_is_internal",
lambda u: (checked.append(u), False)[1])
data = server._fetch_art_url("https://coverartarchive.example/release/x/front-500")
assert data == b"IMGDATA"
assert fetched == ["https://coverartarchive.example/release/x/front-500",
"https://archive.example/img.png"]
assert checked == fetched # every hop was gated
def test_fetch_art_url_blocks_redirect_to_internal(server, monkeypatch):
import requests
monkeypatch.setattr(requests, "get", lambda url, **kw: _FakeResp(
302, {"Location": "http://internal.example/x.png"}))
monkeypatch.setattr(server, "_enrich_network_enabled", lambda: True)
monkeypatch.setattr(server, "_url_host_is_internal",
lambda u: "internal" in u)
with pytest.raises(ValueError):
server._fetch_art_url("https://public.example/x.png")
def test_fetch_art_url_redirect_budget(server, monkeypatch):
import requests
monkeypatch.setattr(requests, "get", lambda url, **kw: _FakeResp(
307, {"Location": "https://public.example/next.png"}))
monkeypatch.setattr(server, "_enrich_network_enabled", lambda: True)
monkeypatch.setattr(server, "_url_host_is_internal", lambda u: False)
with pytest.raises(server.EnrichTransportError):
server._fetch_art_url("https://public.example/x.png")
+1
View File
@@ -29,6 +29,7 @@ def server(tmp_path, monkeypatch, isolate_logging):
finally:
conn = getattr(getattr(srv, "meta_db", None), "conn", None)
if conn is not None:
getattr(__import__("sys").modules.get("server"), "_join_background_db_threads", lambda: None)()
conn.close()
sys.modules.pop("server", None)
+45
View File
@@ -22,6 +22,7 @@ def server(tmp_path, monkeypatch, isolate_logging):
finally:
conn = getattr(getattr(srv, "meta_db", None), "conn", None)
if conn is not None:
getattr(__import__("sys").modules.get("server"), "_join_background_db_threads", lambda: None)()
conn.close()
sys.modules.pop("server", None)
@@ -209,3 +210,47 @@ def test_list_aliases_sorted(client, server):
_alias(client, "guns n roses", "Guns N' Roses")
aliases = client.get("/api/artist-aliases").json()["aliases"]
assert {a["raw_name"] for a in aliases} == {"ACDC", "guns n roses"}
# ── Search (q) matches merged aliases (launch polish) ─────────────────────────
def _search(client, q):
return {s["filename"] for s in
client.get("/api/library", params={"q": q}).json()["songs"]}
def test_search_canonical_finds_raw_variants(client, server):
"""Searching the canonical name must also find songs whose raw tag is a
merged variant — after ACDC→AC/DC, q="AC/DC" returns both."""
_seed(server, "a.archive", "ACDC")
_seed(server, "b.archive", "AC/DC")
_seed(server, "c.archive", "Other")
_alias(client, "ACDC", "AC/DC")
assert _search(client, "AC/DC") == {"a.archive", "b.archive"}
def test_search_partial_canonical_finds_raw_variants(client, server):
"""The alias term is a LIKE, matching the substring semantics of the
plain artist term."""
_seed(server, "a.archive", "ACDC")
_seed(server, "b.archive", "Other")
_alias(client, "ACDC", "AC/DC")
assert _search(client, "c/d") == {"a.archive"}
def test_search_without_aliases_unchanged(client, server):
"""No aliases → the fast path keeps the original 3-term search."""
_seed(server, "a.archive", "ACDC")
_seed(server, "b.archive", "AC/DC")
assert _search(client, "ACDC") == {"a.archive"}
def test_search_title_album_unaffected_by_alias_term(client, server):
"""With aliases present (extra placeholder appended), title/album search
still works — guards the parameter order."""
_seed(server, "a.archive", "ACDC") # title "a"
_alias(client, "ACDC", "AC/DC")
server.meta_db.put("t.archive", 0, 0,
{"title": "Thunder Road", "artist": "Boss", "album": "Born"})
assert _search(client, "Thunder") == {"t.archive"}
assert _search(client, "Born") == {"t.archive"}
+408
View File
@@ -0,0 +1,408 @@
"""Server tests for the artist-pages layer (PR-B, artist-pages launch charrette).
Two halves, mirroring the design's split:
* GET /api/artist/{name}/page — the all-LOCAL payload. Covers the counts /
albums / alias variants, the DENOMINATOR LAW (mastered counts songs YOU OWN,
never anything external — locked position 2), similar-in-library genre
co-occurrence (in-library artists only, self excluded, empty → empty), and
mb_artist_id resolution from matched/manual rows only.
* GET /api/artist/{name}/links + POST .../links/refresh — the lazy, cached,
opt-in external-links layer. The HTTP transport is a fake over
`server._mb_http_get` (the ONE network seam — same pattern as
tests/test_mb_enrichment.py), so nothing here opens a socket. Covers the
url-rel whitelist mapping, the http(s) scheme gate (a hostile javascript:
resource never reaches a link slot), cache-hit second calls making no
network call, the offline guard, the default-OFF setting gate, and the
demo-mode blocks.
"""
import importlib
import json
import sys
from urllib.parse import quote
import pytest
from fastapi.testclient import TestClient
@pytest.fixture()
def server(tmp_path, monkeypatch, isolate_logging):
monkeypatch.setenv("CONFIG_DIR", str(tmp_path / "config"))
dlc = tmp_path / "dlc"
dlc.mkdir()
monkeypatch.setenv("DLC_DIR", str(dlc))
monkeypatch.setenv("FEEDBACK_SKIP_STARTUP_TASKS", "1")
sys.modules.pop("server", None)
srv = importlib.import_module("server")
try:
yield srv
finally:
conn = getattr(getattr(srv, "meta_db", None), "conn", None)
if conn is not None:
getattr(__import__("sys").modules.get("server"), "_join_background_db_threads", lambda: None)()
conn.close()
sys.modules.pop("server", None)
@pytest.fixture()
def client(server):
return TestClient(server.app)
MBID = "66c662b6-6e2f-4930-8610-912e24c63ed1"
def _put(server, fn, title=None, artist="AC/DC", album="", year="",
genre="", duration=200):
server.meta_db.put(fn, 0, 0, {
"title": title or fn.split(".")[0], "artist": artist, "album": album,
"year": year, "genre": genre, "duration": duration,
"arrangements": [{"name": "Lead", "index": 0}],
})
def _pin_match(server, fn, artist_id=MBID):
"""Give a song a user-pinned (manual) match carrying an artist MBID."""
assert server.meta_db.set_enrichment_manual(fn, {
"recording_id": "rec-1", "title": "T", "artist": "AC/DC",
"artist_id": artist_id,
})
def _page(client, name="AC/DC"):
r = client.get("/api/artist/" + quote(name, safe="") + "/page")
assert r.status_code == 200
return r.json()
class FakeMBArtist:
"""Canned MusicBrainz artist lookup over the _mb_http_get seam."""
def __init__(self, srv):
self._srv = srv
self.calls = []
self.doc = artist_doc()
self.raise_transport = False
def __call__(self, path, params):
if self.raise_transport:
raise self._srv.EnrichTransportError("fake network down")
self.calls.append((path, dict(params)))
if path == f"artist/{MBID}":
return self.doc
raise AssertionError(f"unexpected MB path {path!r}")
@pytest.fixture()
def mb_artist(server, monkeypatch):
"""Install the fake transport AND enable the network flag (the test env
disables it by default — see test_links_offline_returns_empty)."""
fake = FakeMBArtist(server)
monkeypatch.setattr(server, "_mb_http_get", fake)
monkeypatch.setattr(server, "_enrich_network_enabled", lambda: True)
return fake
def artist_doc():
"""An MB artist doc exercising the whole whitelist: a hostile javascript:
URL and an ftp:// URL (both must be scheme-gated out), non-whitelisted rel
types (must be dropped), one of each slot, and both wiki rels (wikipedia
must win over wikidata)."""
rel = lambda rtype, url: {"type": rtype, "url": {"resource": url}}
return {
"id": MBID,
"name": "AC/DC",
"relations": [
rel("official homepage", "javascript:alert(1)"), # scheme-gated
rel("official homepage", "https://www.acdc.com"), # first valid wins
rel("official homepage", "https://second.example"),
rel("setlistfm", "https://www.setlist.fm/setlists/acdc"),
rel("youtube", "https://www.youtube.com/acdc"),
rel("social network", "https://www.instagram.com/acdc"),
rel("bandcamp", "ftp://bad.example/acdc"), # scheme-gated
rel("soundcloud", "https://soundcloud.com/acdc"),
rel("wikidata", "https://www.wikidata.org/wiki/Q27593"),
rel("wikipedia", "https://en.wikipedia.org/wiki/AC/DC"),
rel("streaming", "https://stream.example/acdc"), # not whitelisted
rel("purchase for download", "https://store.example"), # not whitelisted
],
"genres": [{"name": "hard rock", "count": 10}, {"name": "rock", "count": 5}],
}
def _enable_links(client):
r = client.post("/api/settings", json={"artist_external_links": True})
assert r.status_code == 200 and "error" not in r.json()
# ── /page: counts, albums, variants ──────────────────────────────────────────
def test_page_counts_albums_and_files(client, server):
_put(server, "a.sloppak", album="The Razors Edge", year="1990")
_put(server, "b.sloppak", album="The Razors Edge", year="1990")
_put(server, "c.sloppak", album="Back in Black", year="1980")
_put(server, "d.sloppak", album="") # loose, no album
_put(server, "x.sloppak", artist="Other Band", album="Elsewhere")
page = _page(client)
assert page["artist"] == "AC/DC"
assert page["song_count"] == 4 # never the other artist
assert page["album_count"] == 2 # empty album ≠ an album
albums = {a["name"]: a for a in page["albums"]}
assert albums["The Razors Edge"]["count"] == 2
assert albums["The Razors Edge"]["year"] == "1990"
assert albums["Back in Black"]["count"] == 1
assert set(page["files"]) == {"a.sloppak", "b.sloppak", "c.sloppak", "d.sloppak"}
# Mosaic art comes from the artist's own songs.
assert page["art_urls"] and all("/art" in u for u in page["art_urls"])
def test_page_unknown_artist_is_zero_count_not_error(client, server):
page = _page(client, "Nobody Here")
assert page["artist"] == "Nobody Here"
assert page["song_count"] == 0
assert page["albums"] == [] and page["similar"] == []
assert page["mb_artist_id"] is None
def test_page_canonicalizes_aliases_and_lists_variants(client, server):
_put(server, "a.sloppak", artist="ACDC", album="Alb")
_put(server, "b.sloppak", artist="AC/DC", album="Alb")
r = client.post("/api/artist-aliases",
json={"raw_name": "ACDC", "canonical_name": "AC/DC"})
assert r.status_code == 200
# Asking by the RAW name lands on the same canonical page.
for name in ("AC/DC", "ACDC"):
page = _page(client, name)
assert page["artist"] == "AC/DC"
assert page["song_count"] == 2 # both variants counted
assert page["variants"] == [{"name": "ACDC", "count": 1}]
# ── /page: the denominator law ────────────────────────────────────────────────
def test_mastered_counts_only_owned_songs(client, server):
"""Locked position 2: 'N mastered' is over songs in YOUR library — a
song_stats row whose file left the library can never inflate it."""
_put(server, "a.sloppak")
_put(server, "b.sloppak")
_put(server, "c.sloppak")
server.meta_db.record_session("a.sloppak", 0, score=100, accuracy=0.95) # mastered
server.meta_db.record_session("b.sloppak", 0, score=50, accuracy=0.5) # in progress
# A mastered score for a song NOT in the library (deleted / renamed) —
# must not count: the denominator is ownership.
server.meta_db.record_session("gone.sloppak", 0, score=100, accuracy=0.99)
page = _page(client)
assert page["song_count"] == 3
assert page["mastered_count"] == 1
assert page["has_stats"] is True
def test_mastered_uses_best_accuracy_across_arrangements(client, server):
_put(server, "a.sloppak")
server.meta_db.record_session("a.sloppak", 0, score=10, accuracy=0.4)
server.meta_db.record_session("a.sloppak", 1, score=90, accuracy=0.93)
assert _page(client)["mastered_count"] == 1
def test_no_practice_data_reports_zero_and_flag(client, server):
"""The frontend omits the mastered segment when it is 0 (invitational —
never '0 mastered'); the payload carries the honest numbers + flag."""
_put(server, "a.sloppak")
page = _page(client)
assert page["mastered_count"] == 0
assert page["has_stats"] is False
# ── /page: similar-in-library ─────────────────────────────────────────────────
def test_similar_ranks_genre_overlap_in_library_only(client, server):
_put(server, "a1.sloppak", artist="AC/DC", genre="Rock")
_put(server, "a2.sloppak", artist="AC/DC", genre="Blues")
_put(server, "b1.sloppak", artist="Band B", genre="rock") # case folds
_put(server, "b2.sloppak", artist="Band B", genre="Blues") # 2 shared genres
_put(server, "c1.sloppak", artist="Band C", genre="Rock") # 1 shared genre
_put(server, "d1.sloppak", artist="Band D", genre="Jazz") # no overlap
similar = _page(client)["similar"]
names = [s["artist"] for s in similar]
assert names[0] == "Band B" # most shared genres
assert "Band C" in names
assert "Band D" not in names # never non-overlapping
assert "AC/DC" not in names # never self
def test_similar_empty_without_genre_data(client, server):
_put(server, "a.sloppak", genre="")
_put(server, "b.sloppak", artist="Band B", genre="Rock")
assert _page(client)["similar"] == []
def test_similar_folds_alias_variants(client, server):
_put(server, "a.sloppak", artist="AC/DC", genre="Rock")
_put(server, "b.sloppak", artist="Band B", genre="Rock")
_put(server, "b2.sloppak", artist="band b", genre="Rock")
client.post("/api/artist-aliases",
json={"raw_name": "band b", "canonical_name": "Band B"})
similar = _page(client)["similar"]
assert [s["artist"] for s in similar] == ["Band B"] # one entry, folded
assert similar[0]["count"] == 2
# ── /page: mb_artist_id resolution ────────────────────────────────────────────
def test_page_mb_artist_id_from_matched_rows(client, server):
_put(server, "a.sloppak")
_pin_match(server, "a.sloppak")
assert _page(client)["mb_artist_id"] == MBID
def test_page_ignores_unmatched_rows_artist_id(client, server):
"""Only matched/manual rows are identity authority — a failed row's
leftover artist_id must not resurface."""
_put(server, "a.sloppak")
server.meta_db.conn.execute(
"INSERT INTO song_enrichment (filename, match_state, mb_artist_id) "
"VALUES ('a.sloppak', 'failed', ?)", (MBID,))
server.meta_db.conn.commit()
assert _page(client)["mb_artist_id"] is None
# ── /links: setting gate, whitelist, scheme gate ─────────────────────────────
def test_links_disabled_by_default_no_network(client, server, mb_artist):
_put(server, "a.sloppak")
_pin_match(server, "a.sloppak")
r = client.get("/api/artist/AC%2FDC/links")
assert r.status_code == 200
body = r.json()
assert body["links"] == {} and body.get("disabled") is True
assert mb_artist.calls == [] # opt-in means opt-in
def test_links_whitelist_mapping_and_scheme_gate(client, server, mb_artist):
_put(server, "a.sloppak")
_pin_match(server, "a.sloppak")
_enable_links(client)
r = client.get("/api/artist/AC%2FDC/links")
assert r.status_code == 200
body = r.json()
assert body["matched"] is True and body["cached"] is False
links = body["links"]
# The javascript: homepage is scheme-gated out; the first VALID one wins.
assert links["official"] == "https://www.acdc.com"
assert links["tour"] == "https://www.setlist.fm/setlists/acdc"
assert links["video"] == "https://www.youtube.com/acdc"
# Social collects; the ftp:// bandcamp is scheme-gated out.
assert links["social"] == ["https://www.instagram.com/acdc",
"https://soundcloud.com/acdc"]
# Wikipedia preferred over wikidata when both exist.
assert links["wikipedia"] == "https://en.wikipedia.org/wiki/AC/DC"
# Nothing hostile or non-whitelisted anywhere in the payload.
dumped = json.dumps(body)
for bad in ("javascript:", "ftp://", "stream.example", "store.example"):
assert bad not in dumped
# One throttled lookup, with the url-rels include.
assert len(mb_artist.calls) == 1
path, params = mb_artist.calls[0]
assert path == f"artist/{MBID}"
assert "url-rels" in params.get("inc", "")
def test_links_wikidata_fallback_when_no_wikipedia(client, server, mb_artist):
_put(server, "a.sloppak")
_pin_match(server, "a.sloppak")
_enable_links(client)
mb_artist.doc = {"id": MBID, "relations": [
{"type": "wikidata", "url": {"resource": "https://www.wikidata.org/wiki/Q27593"}},
], "genres": []}
links = client.get("/api/artist/AC%2FDC/links").json()["links"]
assert links["wikipedia"] == "https://www.wikidata.org/wiki/Q27593"
def test_links_cached_second_call_makes_no_network_call(client, server, mb_artist):
_put(server, "a.sloppak")
_pin_match(server, "a.sloppak")
_enable_links(client)
first = client.get("/api/artist/AC%2FDC/links").json()
assert first["cached"] is False and len(mb_artist.calls) == 1
second = client.get("/api/artist/AC%2FDC/links").json()
assert second["cached"] is True
assert second["links"] == first["links"]
assert len(mb_artist.calls) == 1 # cache hit — no re-fetch
def test_links_refresh_refetches_and_updates_cache(client, server, mb_artist):
_put(server, "a.sloppak")
_pin_match(server, "a.sloppak")
_enable_links(client)
client.get("/api/artist/AC%2FDC/links")
mb_artist.doc = {"id": MBID, "relations": [
{"type": "official homepage", "url": {"resource": "https://new.example"}},
], "genres": []}
r = client.post("/api/artist/AC%2FDC/links/refresh")
assert r.status_code == 200
assert r.json()["links"]["official"] == "https://new.example"
assert len(mb_artist.calls) == 2
# And the refreshed value is what the next GET serves from cache.
again = client.get("/api/artist/AC%2FDC/links").json()
assert again["cached"] is True
assert again["links"]["official"] == "https://new.example"
# ── /links: offline / unmatched / hostile-id guards ──────────────────────────
def test_links_offline_returns_empty(client, server):
"""The test env's offline default (FEEDBACK_SKIP_STARTUP_TASKS) doubles as
the kill-switch test: matched artist + links on, but no network → empty
links, no error, nothing cached."""
_put(server, "a.sloppak")
_pin_match(server, "a.sloppak")
_enable_links(client)
body = client.get("/api/artist/AC%2FDC/links").json()
assert body["links"] == {} and body.get("offline") is True
assert server.meta_db.get_artist_enrichment(MBID) is None
def test_links_unmatched_artist_reports_matched_false(client, server, mb_artist):
_put(server, "a.sloppak") # no enrichment match
_enable_links(client)
body = client.get("/api/artist/AC%2FDC/links").json()
assert body == {"links": {}, "matched": False}
assert mb_artist.calls == []
def test_links_rejects_malformed_stored_mbid(client, server, mb_artist):
"""A hand-rolled /pick body can stuff junk into mb_artist_id — the strict
MBID shape gate must keep it off the MB request line."""
_put(server, "a.sloppak")
_pin_match(server, "a.sloppak", artist_id="evil/../../path")
_enable_links(client)
body = client.get("/api/artist/AC%2FDC/links").json()
assert body == {"links": {}, "matched": False}
assert mb_artist.calls == []
# ── demo mode ─────────────────────────────────────────────────────────────────
def test_links_routes_demo_blocked_page_stays_open(client, server, monkeypatch):
_put(server, "a.sloppak")
_pin_match(server, "a.sloppak")
monkeypatch.setenv("FEEDBACK_DEMO_MODE", "1")
assert client.get("/api/artist/AC%2FDC/links").status_code == 403
assert client.post("/api/artist/AC%2FDC/links/refresh").status_code == 403
# The all-local page read stays available to demo visitors.
assert client.get("/api/artist/AC%2FDC/page").status_code == 200
# ── settings keys ─────────────────────────────────────────────────────────────
def test_artist_page_settings_defaults_and_validation(client, server):
cfg = client.get("/api/settings").json()
assert cfg["artist_pages_enabled"] is True # page is local-only → ON
assert cfg["artist_external_links"] is False # links are opt-in → OFF
# Bool pattern: non-bool shapes return a structured error, not a 500.
for key in ("artist_pages_enabled", "artist_external_links"):
assert "error" in client.post("/api/settings", json={key: "yes"}).json()
assert "error" not in client.post("/api/settings", json={key: True}).json()
assert client.get("/api/settings").json()[key] is True
+1
View File
@@ -22,6 +22,7 @@ def server(tmp_path, monkeypatch, isolate_logging):
finally:
conn = getattr(getattr(srv, "meta_db", None), "conn", None)
if conn is not None:
getattr(__import__("sys").modules.get("server"), "_join_background_db_threads", lambda: None)()
conn.close()
sys.modules.pop("server", None)
+1
View File
@@ -18,6 +18,7 @@ def client(tmp_path, monkeypatch):
for attr in ("meta_db", "audio_effect_mappings"):
conn = getattr(getattr(server, attr, None), "conn", None)
if conn is not None:
getattr(__import__("sys").modules.get("server"), "_join_background_db_threads", lambda: None)()
conn.close()
+2
View File
@@ -34,6 +34,7 @@ def client_and_server(tmp_path, monkeypatch):
meta_db = getattr(server, "meta_db", None)
conn = getattr(meta_db, "conn", None)
if conn is not None:
getattr(__import__("sys").modules.get("server"), "_join_background_db_threads", lambda: None)()
conn.close()
@@ -60,6 +61,7 @@ def non_loopback_client(tmp_path, monkeypatch):
meta_db = getattr(server, "meta_db", None)
conn = getattr(meta_db, "conn", None)
if conn is not None:
getattr(__import__("sys").modules.get("server"), "_join_background_db_threads", lambda: None)()
conn.close()
+1
View File
@@ -22,6 +22,7 @@ def server(tmp_path, monkeypatch, isolate_logging):
finally:
conn = getattr(getattr(srv, "meta_db", None), "conn", None)
if conn is not None:
getattr(__import__("sys").modules.get("server"), "_join_background_db_threads", lambda: None)()
conn.close()
sys.modules.pop("server", None)
+234
View File
@@ -0,0 +1,234 @@
"""Tests for one-time builtin starter-content seeding into DLC."""
from __future__ import annotations
import importlib
import sys
import pytest
@pytest.fixture()
def server_mod(tmp_path, monkeypatch, isolate_logging):
monkeypatch.setenv("CONFIG_DIR", str(tmp_path / "config"))
(tmp_path / "config").mkdir()
monkeypatch.delenv("DLC_DIR", raising=False)
sys.modules.pop("server", None)
mod = importlib.import_module("server")
yield mod
def _source(server_mod):
return (
server_mod._feedBack_server_root()
/ server_mod._BUILTIN_STARTER_SOURCES[0][1]
)
def _dest(server_mod, dlc):
return (
dlc
/ server_mod._BUILTIN_STARTER_SUBDIR
/ server_mod._BUILTIN_STARTER_SOURCES[0][0]
)
def test_seed_creates_starter_content_and_marker(tmp_path, server_mod):
"""First run copies the bundled feedpak into starter/ and writes the marker."""
dlc = tmp_path / "dlc"
dlc.mkdir()
source = _source(server_mod)
if not source.is_file():
pytest.skip(f"starter source not present in checkout: {source}")
server_mod._seed_builtin_starter_content(dlc)
dest = _dest(server_mod, dlc)
assert dest.is_file()
assert dest.stat().st_size == source.stat().st_size
assert (server_mod.CONFIG_DIR / server_mod._STARTER_SEED_MARKER).is_file()
def test_seed_preserves_source_mtime(tmp_path, server_mod):
"""The seeded pack keeps the bundle's mtime so the diagnostic refresh check
(source newer than dest -> update) stays correct across both write paths."""
dlc = tmp_path / "dlc"
dlc.mkdir()
source = _source(server_mod)
if not source.is_file():
pytest.skip(f"starter source not present in checkout: {source}")
server_mod._seed_builtin_starter_content(dlc)
assert _dest(server_mod, dlc).stat().st_mtime_ns == source.stat().st_mtime_ns
def test_starter_is_not_carved_out_of_the_library():
"""`starter/` must NOT collide with the diagnostics/tutorials carve-out —
otherwise seeded songs would never appear in the library listing."""
assert "starter" not in {"diagnostics-builtin", "tutorials-builtin"}
def test_seed_runs_only_once_and_respects_deletion(tmp_path, server_mod):
"""After the first seed, deleting the song does NOT bring it back: the
marker makes starter seeding a one-time welcome."""
dlc = tmp_path / "dlc"
dlc.mkdir()
source = _source(server_mod)
if not source.is_file():
pytest.skip(f"starter source not present in checkout: {source}")
server_mod._seed_builtin_starter_content(dlc)
dest = _dest(server_mod, dlc)
assert dest.is_file()
# User removes the starter song.
dest.unlink()
# A subsequent launch must not re-seed it.
server_mod._seed_builtin_starter_content(dlc)
assert not dest.exists()
def test_seed_deferred_until_dlc_configured(tmp_path, server_mod):
"""With no DLC folder, seeding is skipped WITHOUT writing the marker, so it
retries once a library folder exists."""
source = _source(server_mod)
if not source.is_file():
pytest.skip(f"starter source not present in checkout: {source}")
# dlc is None and DLC_DIR unset -> _get_dlc_dir() returns None.
server_mod._seed_builtin_starter_content(None)
assert not (server_mod.CONFIG_DIR / server_mod._STARTER_SEED_MARKER).exists()
# Now a DLC is configured: the deferred seed runs.
dlc = tmp_path / "dlc"
dlc.mkdir()
server_mod._seed_builtin_starter_content(dlc)
assert _dest(server_mod, dlc).is_file()
assert (server_mod.CONFIG_DIR / server_mod._STARTER_SEED_MARKER).is_file()
def test_seed_refuses_symlinked_seed_directory(tmp_path, server_mod):
"""A symlinked starter/ dir is refused so copies can't escape the DLC tree."""
dlc = tmp_path / "dlc"
dlc.mkdir()
source = _source(server_mod)
if not source.is_file():
pytest.skip(f"starter source not present in checkout: {source}")
outside_dir = tmp_path / "outside"
outside_dir.mkdir()
(dlc / server_mod._BUILTIN_STARTER_SUBDIR).symlink_to(outside_dir)
server_mod._seed_builtin_starter_content(dlc)
assert list(outside_dir.iterdir()) == []
# An incomplete seed must NOT write the marker, so a later launch retries.
assert not (server_mod.CONFIG_DIR / server_mod._STARTER_SEED_MARKER).exists()
def test_seed_never_overwrites_an_existing_user_file(tmp_path, server_mod):
"""One-time starter seeding must never replace a user's own file at the
destination, even if the bundled pack has a newer mtime."""
import os as _os
dlc = tmp_path / "dlc"
dlc.mkdir()
dest = _dest(server_mod, dlc)
dest.parent.mkdir(parents=True, exist_ok=True)
dest.write_bytes(b"user's own edited pack")
_os.utime(dest, (1_000_000, 1_000_000)) # far older than the bundled source
server_mod._seed_builtin_starter_content(dlc)
assert dest.read_bytes() == b"user's own edited pack" # untouched
# counted as already-present, so the one-time seed considers itself done
assert (server_mod.CONFIG_DIR / server_mod._STARTER_SEED_MARKER).is_file()
def test_seed_does_not_mark_when_destination_is_a_directory(tmp_path, server_mod):
"""A directory sitting at the destination name is neither clobbered nor
counted as present, so the marker stays unwritten and seeding retries."""
dlc = tmp_path / "dlc"
dlc.mkdir()
source = _source(server_mod)
if not source.is_file():
pytest.skip(f"starter source not present in checkout: {source}")
bogus = _dest(server_mod, dlc)
bogus.parent.mkdir(parents=True, exist_ok=True)
bogus.mkdir() # user (or junk) placed a directory where the pack goes
server_mod._seed_builtin_starter_content(dlc)
assert bogus.is_dir() # untouched
assert not (server_mod.CONFIG_DIR / server_mod._STARTER_SEED_MARKER).exists()
def test_seed_does_not_mark_when_source_missing(tmp_path, server_mod, monkeypatch):
"""If a starter source can't be found, the marker stays unwritten and the
seed is retried on the next launch (rather than permanently skipped)."""
dlc = tmp_path / "dlc"
dlc.mkdir()
monkeypatch.setattr(
server_mod,
"_BUILTIN_STARTER_SOURCES",
[("missing.feedpak", "content/starter/does-not-exist.feedpak")],
)
server_mod._seed_builtin_starter_content(dlc)
assert not (dlc / server_mod._BUILTIN_STARTER_SUBDIR / "missing.feedpak").exists()
assert not (server_mod.CONFIG_DIR / server_mod._STARTER_SEED_MARKER).exists()
def test_every_starter_source_file_is_present(server_mod):
"""Every entry in _BUILTIN_STARTER_SOURCES must have its bundled file on
disk — otherwise the all-present gate never fires and NOTHING seeds (a
listed-but-missing pack silently disables starter seeding entirely). In CI
the checkout is clean, so "on disk" == committed."""
root = server_mod._feedBack_server_root()
missing = [
rel for _, rel in server_mod._BUILTIN_STARTER_SOURCES
if not (root / rel).is_file()
]
assert not missing, f"listed starter sources missing on disk: {missing}"
def test_seed_lands_every_listed_starter_pack(tmp_path, server_mod):
"""A real seed run copies every listed pack into starter/ and marks done."""
root = server_mod._feedBack_server_root()
for _, rel in server_mod._BUILTIN_STARTER_SOURCES:
if not (root / rel).is_file():
pytest.skip(f"starter source not present in checkout: {rel}")
dlc = tmp_path / "dlc"
dlc.mkdir()
server_mod._seed_builtin_starter_content(dlc)
for dest_name, _ in server_mod._BUILTIN_STARTER_SOURCES:
dest = dlc / server_mod._BUILTIN_STARTER_SUBDIR / dest_name
assert dest.is_file(), f"pack not seeded: {dest_name}"
assert (server_mod.CONFIG_DIR / server_mod._STARTER_SEED_MARKER).is_file()
def test_no_unlisted_starter_pack_on_disk(server_mod):
"""The inverse guard: every content/starter/*.feedpak on disk must be wired
into _BUILTIN_STARTER_SOURCES. An unlisted pack bundles into builds as dead
weight and never seeds — exactly how the raw Ode-to-Joy pack slipped onto
main before being wired up. In CI the checkout is clean, so this flags any
stray/committed pack that isn't listed."""
root = server_mod._feedBack_server_root()
listed = {rel for _, rel in server_mod._BUILTIN_STARTER_SOURCES}
if not listed:
pytest.skip("no starter sources declared")
content_dir = (root / next(iter(listed))).parent # all sources share this dir
if not content_dir.is_dir():
pytest.skip(f"starter content dir absent: {content_dir}")
on_disk = {p.relative_to(root).as_posix() for p in content_dir.glob("*.feedpak")}
unlisted = on_disk - listed
assert not unlisted, (
"committed but not in _BUILTIN_STARTER_SOURCES (would bundle as dead "
f"weight and never seed): {sorted(unlisted)}"
)
+1
View File
@@ -21,6 +21,7 @@ def server_mod(tmp_path, monkeypatch):
yield mod
conn = getattr(getattr(mod, "meta_db", None), "conn", None)
if conn is not None:
getattr(__import__("sys").modules.get("server"), "_join_background_db_threads", lambda: None)()
conn.close()
+1
View File
@@ -24,6 +24,7 @@ def server(tmp_path, monkeypatch, isolate_logging):
finally:
conn = getattr(getattr(srv, "meta_db", None), "conn", None)
if conn is not None:
getattr(__import__("sys").modules.get("server"), "_join_background_db_threads", lambda: None)()
conn.close()
sys.modules.pop("server", None)
+2
View File
@@ -36,6 +36,7 @@ def client(tmp_path, monkeypatch):
finally:
conn = getattr(getattr(server, "meta_db", None), "conn", None)
if conn is not None:
getattr(__import__("sys").modules.get("server"), "_join_background_db_threads", lambda: None)()
conn.close()
@@ -169,6 +170,7 @@ def test_server_app_request_id_propagated_to_logs(monkeypatch, tmp_path):
]
conn = getattr(getattr(server_mod, "meta_db", None), "conn", None)
if conn is not None:
getattr(__import__("sys").modules.get("server"), "_join_background_db_threads", lambda: None)()
conn.close()
lines = [ln for ln in buf.getvalue().splitlines() if "server_probe_event" in ln]
+1
View File
@@ -20,6 +20,7 @@ def server(tmp_path, monkeypatch, isolate_logging):
finally:
conn = getattr(getattr(srv, "meta_db", None), "conn", None)
if conn is not None:
getattr(__import__("sys").modules.get("server"), "_join_background_db_threads", lambda: None)()
conn.close()
sys.modules.pop("server", None)
+2
View File
@@ -60,6 +60,7 @@ def _cleanup(server, client):
server._DEMO_JANITOR_HOOKS.clear()
conn = getattr(getattr(server, "meta_db", None), "conn", None)
if conn is not None:
getattr(__import__("sys").modules.get("server"), "_join_background_db_threads", lambda: None)()
conn.close()
@@ -311,6 +312,7 @@ def test_register_demo_janitor_hook_in_plugin_context(tmp_path, monkeypatch):
conn = getattr(getattr(server, "meta_db", None), "conn", None)
if conn is not None:
getattr(__import__("sys").modules.get("server"), "_join_background_db_threads", lambda: None)()
conn.close()
# Clean up janitor state so it doesn't bleed into other tests.
server._DEMO_JANITOR_STOP.set()
+4 -4
View File
@@ -204,7 +204,7 @@ def test_client_audio_session_contribution_redacts_paths(tmp_path):
kw["client_contributions"] = {
"note_detect": {
"schema": "feedBack.audio_session.diagnostics.v1",
"session": {"sessionId": str(home_path / "DLC" / "private-song.archive")},
"session": {"sessionId": str(home_path / "DLC" / "private-song.feedpak")},
"domains": {"audio-input": {"sources": [{"label": str(home_path / "devices" / "raw-id")}]}},
}
}
@@ -1541,7 +1541,7 @@ def test_console_error_object_args_are_redacted(tmp_path):
kw = _basic_kwargs(tmp_path)
kw["include"]["console"] = True
kw["redact"] = True
secret_path = "/home/alice/Music/DLC/my_song.archive"
secret_path = "/home/alice/Music/DLC/my_song.feedpak"
kw["client_console"] = [
{
"level": "error",
@@ -1567,13 +1567,13 @@ def test_console_string_args_still_redacted(tmp_path):
kw["include"]["console"] = True
kw["redact"] = True
kw["client_console"] = [
{"level": "log", "msg": "ok", "args": ["loaded /home/alice/Music/DLC/my_song.archive ok"]},
{"level": "log", "msg": "ok", "args": ["loaded /home/alice/Music/DLC/my_song.feedpak ok"]},
]
zip_bytes, _name, _m = db.build_bundle(**kw)
with _open_zip(zip_bytes) as zf:
console = json.loads(zf.read("client/console.json"))
# The song filename should be replaced with a hash token, not appear verbatim.
assert "my_song.archive" not in console["entries"][0]["args"][0]
assert "my_song.feedpak" not in console["entries"][0]["args"][0]
def test_console_non_string_non_dict_args_pass_through(tmp_path):
+5 -5
View File
@@ -5,7 +5,7 @@ from diagnostics_redact import Redactor
def test_dlc_path_replaced():
r = Redactor(dlc_dir=Path("/dlc/songs"))
out = r.redact_text("loaded from /dlc/songs/foo.archive")
out = r.redact_text("loaded from /dlc/songs/foo.feedpak")
assert "<DLC_DIR>" in out
assert "/dlc/songs" not in out
assert r.counts["paths_replaced"] == 1
@@ -13,8 +13,8 @@ def test_dlc_path_replaced():
def test_song_filename_redacted_consistently():
r = Redactor()
a = r.redact_text("Loading Test-Artist_Test-Song.archive")
b = r.redact_text("Replaying Test-Artist_Test-Song.archive again")
a = r.redact_text("Loading Test-Artist_Test-Song.feedpak")
b = r.redact_text("Replaying Test-Artist_Test-Song.feedpak again")
token_a = a.split("Loading ")[1].strip()
token_b = b.split("Replaying ")[1].split(" ")[0]
assert token_a == token_b
@@ -63,8 +63,8 @@ def test_home_dir_replaced():
def test_different_redactors_produce_different_tokens():
a = Redactor()
b = Redactor()
out_a = a.redact_text("Foo.archive")
out_b = b.redact_text("Foo.archive")
out_a = a.redact_text("Foo.feedpak")
out_b = b.redact_text("Foo.feedpak")
assert out_a != out_b
+92
View File
@@ -0,0 +1,92 @@
"""Unit tests for ``server._resolve_dlc_path`` — the DLC-library containment
guard.
It must (1) allow a library mounted through a directory JUNCTION/symlink (the
shared-library-across-installs / desktop-app case that a ``.resolve()``-based
check wrongly rejected, breaking album art + song load), while (2) still
rejecting ``..`` traversal and absolute paths — the only escapes a ``:path``
filename can express. ``safe_join`` stays strict on purpose (zip-slip guard),
so the contrast is pinned here too.
"""
import importlib
import os
import sys
import pytest
@pytest.fixture()
def server(tmp_path, monkeypatch):
(tmp_path / "cfg").mkdir()
monkeypatch.setenv("CONFIG_DIR", str(tmp_path / "cfg"))
monkeypatch.setenv("FEEDBACK_SKIP_STARTUP_TASKS", "1")
sys.modules.pop("server", None)
srv = importlib.import_module("server")
try:
yield srv
finally:
conn = getattr(getattr(srv, "meta_db", None), "conn", None)
if conn is not None:
getattr(sys.modules.get("server"), "_join_background_db_threads", lambda: None)()
conn.close()
sys.modules.pop("server", None)
def _dlc(tmp_path):
d = tmp_path / "dlc"
d.mkdir()
return d
# ── still-rejected escapes (the security contract) ────────────────────────────
def test_dotdot_traversal_rejected(server, tmp_path):
dlc = _dlc(tmp_path)
assert server._resolve_dlc_path(dlc, "../../etc/passwd") is None
# a Windows-style backslash traversal is normalised + rejected identically
assert server._resolve_dlc_path(dlc, "..\\..\\secret") is None
assert server._resolve_dlc_path(dlc, "a/../../b") is None
def test_absolute_path_rejected(server, tmp_path):
dlc = _dlc(tmp_path)
assert server._resolve_dlc_path(dlc, "/etc/passwd") is None
assert server._resolve_dlc_path(dlc, "C:/Windows/system32/x") is None
def test_empty_and_nul_rejected(server, tmp_path):
dlc = _dlc(tmp_path)
assert server._resolve_dlc_path(dlc, "") is None
assert server._resolve_dlc_path(dlc, "a\x00b") is None
# ── allowed: legitimate in-library paths ──────────────────────────────────────
def test_safe_relative_allowed(server, tmp_path):
dlc = _dlc(tmp_path)
p = server._resolve_dlc_path(dlc, "CDLC/City Pop/song.feedpak")
assert p is not None
assert p.is_relative_to(dlc.resolve())
def test_junction_subfolder_allowed(server, tmp_path):
"""A library mounted through a directory junction/symlink must resolve —
the case that broke album art for Christian's shared city-pop library."""
dlc = _dlc(tmp_path)
real = tmp_path / "real_library"
real.mkdir()
(real / "song.feedpak").write_bytes(b"pack")
link = dlc / "CDLC"
try:
os.symlink(real, link, target_is_directory=True)
except (OSError, NotImplementedError):
pytest.skip("symlink/junction creation not permitted on this host")
p = server._resolve_dlc_path(dlc, "CDLC/song.feedpak")
assert p is not None, "a junctioned library subfolder was wrongly rejected"
assert p.exists(), "the resolved path should reach the file through the junction"
# Contrast: safe_join stays strict (it .resolve()s and follows the junction
# to its real target outside the root), which is correct for its zip-slip
# callers but is exactly why _resolve_dlc_path can't reuse it here.
assert server.safe_join(dlc, "CDLC/song.feedpak") is None
+150
View File
@@ -22,6 +22,7 @@ def server(tmp_path, monkeypatch, isolate_logging):
finally:
conn = getattr(getattr(srv, "meta_db", None), "conn", None)
if conn is not None:
getattr(__import__("sys").modules.get("server"), "_join_background_db_threads", lambda: None)()
conn.close()
sys.modules.pop("server", None)
@@ -149,3 +150,152 @@ def test_art_cache_dir_created(server):
d = server._enrichment_art_dir()
assert d.is_dir()
assert d.name == "art_cache"
# ── Refresh Metadata batch: per-tile states, progress, Stop ───────────────────
def test_states_for_returns_only_known_filenames(server):
_put(server, "a.archive")
server._background_enrich()
got = server.meta_db.enrichment_states_for(["a.archive", "nope.archive"])
assert got == {"a.archive": "unscanned"} # unknown filename absent
assert server.meta_db.enrichment_states_for([]) == {}
def test_states_endpoint(client, server):
_put(server, "a.archive")
_put(server, "b.archive", title="Other")
server._background_enrich()
body = client.post("/api/enrichment/states",
json={"filenames": ["a.archive", "zzz.missing"]}).json()
assert body["states"] == {"a.archive": "unscanned"}
assert body["running"] is False
assert body["current"] is None
def test_status_exposes_progress_fields(client, server):
_put(server, "a.archive")
server._background_enrich()
body = client.get("/api/enrichment/status").json()
for k in ("total", "matched", "current", "cancelling"):
assert k in body
assert body["cancelling"] is False
def test_cancel_is_noop_when_idle(client, server):
body = client.post("/api/enrichment/cancel").json()
assert body == {"ok": True, "was_running": False}
# A no-op must not arm the flag (which would then poison the next pass).
assert server._enrich_cancel.is_set() is False
def test_cancel_flag_halts_matching_loop_between_songs(server, monkeypatch):
for i in range(4):
_put(server, f"s{i}.archive", title=f"Song {i}")
# Force the matcher path on (the test env is offline by default) and stub the
# per-song matcher so nothing touches the network — it just trips Stop after
# the first song, exactly as the /cancel route would mid-pass.
monkeypatch.setattr(server, "_enrich_network_enabled", lambda: True)
calls = []
def fake_enrich_one(row, **_kw):
calls.append(row["filename"])
server._enrich_cancel.set()
monkeypatch.setattr(server, "_enrich_one", fake_enrich_one)
server._enrich_cancel.clear()
server._background_enrich()
# The loop checks cancel BEFORE each song, so exactly one is processed before
# it breaks — not the whole 4-row queue.
assert calls == ["s0.archive"]
assert server._enrich_status["total"] == 4
assert server._enrich_status["matched"] == 1
def test_rematch_requeues_visible_but_skips_manual(server, client):
_put(server, "a.archive") # will be 'matched'
_put(server, "b.archive", title="Other") # will be 'failed'
_put(server, "c.archive", title="Pinned") # will be 'manual' — untouchable
server._background_enrich()
with server.meta_db._lock:
server.meta_db.conn.execute(
"UPDATE song_enrichment SET match_state='matched' WHERE filename='a.archive'")
server.meta_db.conn.execute(
"UPDATE song_enrichment SET match_state='failed' WHERE filename='b.archive'")
server.meta_db.conn.execute(
"UPDATE song_enrichment SET match_state='manual' WHERE filename='c.archive'")
server.meta_db.conn.commit()
body = client.post("/api/enrichment/rematch", json={
"filenames": ["a.archive", "b.archive", "c.archive", "nope.archive"]}).json()
# A per-view refresh re-runs everything shown EXCEPT the manual pin (and an
# unknown filename); matched + failed are both re-queued.
assert set(body["queued"]) == {"a.archive", "b.archive"}
assert body["count"] == 2
server._join_background_db_threads()
assert server.meta_db.get_enrichment("a.archive")["match_state"] == "unscanned"
assert server.meta_db.get_enrichment("b.archive")["match_state"] == "unscanned"
assert server.meta_db.get_enrichment("c.archive")["match_state"] == "manual"
# ── filename-derived artist/title fallback (blank-artist packs) ───────────────
def test_filename_artist_title_parse(server):
f = server._artist_title_from_filename
assert f("CDLC/0 - City Pop/Tatsuro-Yamashita_Ride-On-Time_v1_p.feedpak") == \
{"artist": "Tatsuro Yamashita", "title": "Ride On Time"}
assert f("Anri_Windy-Summer_v1_p.feedpak") == {"artist": "Anri", "title": "Windy Summer"}
# a trailing "(440Hz)" retune tag is stripped before parsing
assert f("Cindy_Watashitachi-o-Shinjite-Ite_v1_p (440Hz).feedpak") == \
{"artist": "Cindy", "title": "Watashitachi o Shinjite Ite"}
# doesn't fit the convention → no guess
assert f("nounderscore.feedpak") is None
def test_blank_artist_seeds_match_from_filename(server, monkeypatch):
server.meta_db.put("Tatsuro-Yamashita_Ride-On-Time_v1_p.feedpak", 0, 0, {
"title": "Tatsuro-Yamashita_Ride-On-Time_v1_p", "artist": "", "album": "",
"duration": 240, "arrangements": [{"name": "Bass", "index": 0}]})
monkeypatch.setattr(server, "_enrich_network_enabled", lambda: True)
monkeypatch.setattr(server, "_manifest_exact_ids", lambda fn: {})
seen = {}
def fake_search(artist, title, limit=8):
seen["artist"], seen["title"] = artist, title
return []
monkeypatch.setattr(server, "_mb_search_recordings", fake_search)
row = next(r for r in server.meta_db.enrichment_pending()
if r["filename"].startswith("Tatsuro"))
server._enrich_one(row)
# the blank pack artist was replaced by the filename-derived identity for
# the search (this is exactly what rescues the 'failed' pile)
assert seen == {"artist": "Tatsuro Yamashita", "title": "Ride On Time"}
def test_present_artist_is_not_overridden_by_filename(server, monkeypatch):
server.meta_db.put("Weird-Filename_x_y.feedpak", 0, 0, {
"title": "Real Title", "artist": "Real Artist", "album": "", "duration": 100,
"arrangements": [{"name": "Lead", "index": 0}]})
monkeypatch.setattr(server, "_enrich_network_enabled", lambda: True)
monkeypatch.setattr(server, "_manifest_exact_ids", lambda fn: {})
seen = {}
def fake_search(artist, title, limit=8):
seen["artist"], seen["title"] = artist, title
return []
monkeypatch.setattr(server, "_mb_search_recordings", fake_search)
row = next(r for r in server.meta_db.enrichment_pending()
if r["filename"].startswith("Weird"))
server._enrich_one(row)
# a pack that DOES carry an artist keeps it — the filename is never consulted
assert seen == {"artist": "Real Artist", "title": "Real Title"}
def test_kick_clears_a_stale_cancel(server):
# A cancelled-then-rekicked pass must start clean: _kick_enrich clears the
# flag so the fresh pass isn't aborted the instant it checks.
server._enrich_cancel.set()
server._kick_enrich()
server._join_background_db_threads()
assert server._enrich_cancel.is_set() is False
+3
View File
@@ -100,6 +100,7 @@ def scan_server(tmp_path, monkeypatch, isolate_logging):
yield mod
conn = getattr(getattr(mod, "meta_db", None), "conn", None)
if conn is not None:
getattr(__import__("sys").modules.get("server"), "_join_background_db_threads", lambda: None)()
conn.close()
@@ -161,6 +162,7 @@ def upload_client(tmp_path, monkeypatch):
tc.close()
conn = getattr(getattr(server, "meta_db", None), "conn", None)
if conn is not None:
getattr(__import__("sys").modules.get("server"), "_join_background_db_threads", lambda: None)()
conn.close()
@@ -228,6 +230,7 @@ def settings_server(tmp_path, monkeypatch):
finally:
conn = getattr(getattr(server, "meta_db", None), "conn", None)
if conn is not None:
getattr(__import__("sys").modules.get("server"), "_join_background_db_threads", lambda: None)()
conn.close()
+2
View File
@@ -29,6 +29,7 @@ def server(tmp_path, monkeypatch, isolate_logging):
finally:
conn = getattr(getattr(srv, "meta_db", None), "conn", None)
if conn is not None:
getattr(__import__("sys").modules.get("server"), "_join_background_db_threads", lambda: None)()
conn.close()
sys.modules.pop("server", None)
@@ -255,5 +256,6 @@ def test_demo_mode_blocks_write(tmp_path, monkeypatch, isolate_logging):
finally:
conn = getattr(getattr(srv, "meta_db", None), "conn", None)
if conn is not None:
getattr(__import__("sys").modules.get("server"), "_join_background_db_threads", lambda: None)()
conn.close()
sys.modules.pop("server", None)
+1
View File
@@ -20,6 +20,7 @@ def server(tmp_path, monkeypatch, isolate_logging):
finally:
conn = getattr(getattr(srv, "meta_db", None), "conn", None)
if conn is not None:
getattr(__import__("sys").modules.get("server"), "_join_background_db_threads", lambda: None)()
conn.close()
sys.modules.pop("server", None)
+2
View File
@@ -30,6 +30,7 @@ def server_mod(monkeypatch, tmp_path):
yield mod
conn = getattr(getattr(mod, "meta_db", None), "conn", None)
if conn is not None:
getattr(__import__("sys").modules.get("server"), "_join_background_db_threads", lambda: None)()
conn.close()
@@ -124,6 +125,7 @@ def make_client(tmp_path, monkeypatch):
server = sys.modules.get("server")
conn = getattr(getattr(server, "meta_db", None), "conn", None)
if conn is not None:
getattr(__import__("sys").modules.get("server"), "_join_background_db_threads", lambda: None)()
conn.close()
@@ -103,6 +103,7 @@ def make_client(tmp_path, monkeypatch):
server = sys.modules.get("server")
conn = getattr(getattr(server, "meta_db", None), "conn", None)
if conn is not None:
getattr(__import__("sys").modules.get("server"), "_join_background_db_threads", lambda: None)()
conn.close()
+1
View File
@@ -130,6 +130,7 @@ def make_client(tmp_path, monkeypatch):
server = sys.modules.get("server")
conn = getattr(getattr(server, "meta_db", None), "conn", None)
if conn is not None:
getattr(__import__("sys").modules.get("server"), "_join_background_db_threads", lambda: None)()
conn.close()
+1
View File
@@ -24,6 +24,7 @@ def server_mod(tmp_path, monkeypatch):
yield mod
conn = getattr(getattr(mod, "meta_db", None), "conn", None)
if conn is not None:
getattr(__import__("sys").modules.get("server"), "_join_background_db_threads", lambda: None)()
conn.close()
+1
View File
@@ -23,6 +23,7 @@ def server_mod(tmp_path, monkeypatch):
yield mod
conn = getattr(getattr(mod, "meta_db", None), "conn", None)
if conn is not None:
getattr(__import__("sys").modules.get("server"), "_join_background_db_threads", lambda: None)()
conn.close()
+2
View File
@@ -15,6 +15,7 @@ def server_mod(tmp_path, monkeypatch):
yield mod
conn = getattr(getattr(mod, "meta_db", None), "conn", None)
if conn is not None:
getattr(__import__("sys").modules.get("server"), "_join_background_db_threads", lambda: None)()
conn.close()
@@ -298,4 +299,5 @@ def test_library_provider_registration_is_available_to_plugins(tmp_path, monkeyp
assert captured["unregister_library_provider"] is server.unregister_library_provider
finally:
if conn is not None:
getattr(__import__("sys").modules.get("server"), "_join_background_db_threads", lambda: None)()
conn.close()
+1
View File
@@ -37,6 +37,7 @@ def dlc_client(tmp_path, monkeypatch):
meta_db = getattr(server, "meta_db", None)
conn = getattr(meta_db, "conn", None)
if conn is not None:
getattr(__import__("sys").modules.get("server"), "_join_background_db_threads", lambda: None)()
conn.close()
+1
View File
@@ -28,6 +28,7 @@ def server(tmp_path, monkeypatch, isolate_logging):
finally:
conn = getattr(getattr(srv, "meta_db", None), "conn", None)
if conn is not None:
getattr(__import__("sys").modules.get("server"), "_join_background_db_threads", lambda: None)()
conn.close()
sys.modules.pop("server", None)
+53 -1
View File
@@ -145,11 +145,40 @@ def test_rank_candidates_orders_by_our_score():
assert all("score" in c for c in ranked)
def test_rank_candidates_studio_preference_is_dropped_for_live_charts():
"""Tied-score candidates: a studio chart prefers the studio take, but a
LIVE chart must NOT be forced to the studio recording."""
studio = {"recording_id": "studio", "artist": "AC/DC", "title": "Highway to Hell",
"studio": True, "mb_score": 90}
live = {"recording_id": "live", "artist": "AC/DC", "title": "Highway to Hell",
"studio": False, "mb_score": 95}
# Studio chart -> studio take wins the tie (studio flag), despite lower mb_score.
studio_song = {"artist": "AC/DC", "title": "Highway to Hell"}
assert m.rank_candidates(studio_song, [live, studio])[0]["recording_id"] == "studio"
# Live chart -> studio preference dropped, so the higher-mb_score live take wins.
live_song = {"artist": "AC/DC", "title": "Highway to Hell (Live at Donington)"}
assert m.rank_candidates(live_song, [studio, live])[0]["recording_id"] == "live"
# ── query building ────────────────────────────────────────────────────────────
def test_build_recording_query_denoises_and_quotes():
q = m.build_recording_query("ACDC", 'Thunderstruck (v2)')
assert q == 'recording:"thunderstruck" AND artist:"acdc"'
# Live-only recordings are excluded — the studio take is never tagged Live,
# and it's the biggest source of junk in a flat recording search.
assert q == 'recording:"thunderstruck" AND artist:"acdc" AND -secondarytype:Live'
def test_build_recording_query_keeps_live_for_live_charts():
"""A chart that IS a live take must NOT get the live filter, or its only
correct recording is excluded. A bare title word ("Live and Let Die") is a
real word, not a marker, so it still filters."""
live = m.build_recording_query("AC/DC", "Highway to Hell (Live at Donington)")
assert "-secondarytype:Live" not in live
assert 'recording:"highway to hell"' in live
# A real word "live" in the title is not a live marker → still filtered.
bare = m.build_recording_query("Wings", "Live and Let Die")
assert "-secondarytype:Live" in bare
def test_build_recording_query_escapes_and_handles_missing_artist():
@@ -200,6 +229,29 @@ def test_parse_recording_doc_normalizes():
assert c["mb_score"] == 98
def test_best_release_prefers_official_single_over_unofficial_album():
"""An OFFICIAL single/EP must outrank an UNofficial bootleg album for the
canonical album/year: official comes before the studio-album preference, so
a single-only song is never seeded from a bootleg. (`(clean, status_ok, …)`
would wrongly pick the bootleg.)"""
doc = {
"id": "rec-x", "title": "One-Off", "score": 90,
"artist-credit": [
{"name": "A", "joinphrase": "",
"artist": {"id": "a", "name": "A", "sort-name": "A"}}],
"releases": [
{"id": "rel-boot", "title": "Boot LP", "status": "Bootleg",
"date": "1990-01-01", "release-group": {"primary-type": "Album"}},
{"id": "rel-single", "title": "The Single", "status": "Official",
"date": "1988-01-01", "release-group": {"primary-type": "Single"}},
],
}
c = m.parse_recording_doc(doc)
assert c["release_id"] == "rel-single"
assert c["album"] == "The Single"
assert c["studio"] is False # a Single isn't a clean studio ALBUM
def test_parse_recording_doc_joined_artist_credit():
doc = dict(MB_DOC)
doc["artist-credit"] = [
+1
View File
@@ -409,6 +409,7 @@ def test_db_uses_wal_journal_mode(setup_routes):
row = conn.execute("PRAGMA journal_mode").fetchone()
assert row[0] == "wal"
finally:
getattr(__import__("sys").modules.get("server"), "_join_background_db_threads", lambda: None)()
conn.close()
+1
View File
@@ -18,6 +18,7 @@ def server(tmp_path, monkeypatch, isolate_logging):
finally:
conn = getattr(getattr(srv, "meta_db", None), "conn", None)
if conn is not None:
getattr(__import__("sys").modules.get("server"), "_join_background_db_threads", lambda: None)()
conn.close()
sys.modules.pop("server", None)
+17 -7
View File
@@ -38,15 +38,25 @@ def test_plugin_loader_unmounts_previous_ui_contributions_before_reregistering()
assert "await _commandUiDomain(contribution.domain, 'mount', plugin, contribution)" in source
def test_plugin_loader_unmounts_contributions_for_removed_plugins():
def test_plugin_loader_does_not_treat_response_absence_as_uninstall():
# A plugin transiently absent from /api/plugins (the backend clears its
# registry at the start of load_plugins() and repopulates incrementally
# while HTTP stays up, so restarts serve partial responses) must NOT be
# torn down: the old absence sweep unmounted UI contributions and
# unregistered the capability participant with no re-registration path
# (plugin scripts don't re-run), and the DOM/style wipes forced a
# mid-session screen.js re-evaluation that duplicated the desktop
# audio_engine's native signal chain.
source = (ROOT / "static" / "app.js").read_text(encoding="utf-8")
assert "const livePluginIds = new Set(plugins.map((plugin) => plugin.id))" in source
assert "for (const [pluginId, contributions] of _pluginUiContributions)" in source
assert "const stalePlugin = { id: pluginId }" in source
assert "await _commandUiDomain(contribution.domain, 'unmount', stalePlugin, contribution)" in source
assert "window.feedBack?.capabilities?.unregisterParticipant?.(pluginId)" in source
assert "_pluginUiContributions.delete(pluginId)" in source
# The absence-triggered sweep is gone (rationale comment in its place)...
assert "const livePluginIds" not in source
assert "const stalePlugin = { id: pluginId }" not in source
assert "deliberately NO stale-contribution sweep" in source
# ...and the DOM/style reconcilers only act on plugins the response names.
assert "const respondedIds = new Set(plugins.map((p) => p.id))" in source
assert "respondedIds.has(pid) && !alreadyHydrated.has(pid)" in source
assert "responded.has(id) && !styled.has(id)" in source
+1
View File
@@ -24,6 +24,7 @@ def server(tmp_path, monkeypatch, isolate_logging):
finally:
conn = getattr(getattr(srv, "meta_db", None), "conn", None)
if conn is not None:
getattr(__import__("sys").modules.get("server"), "_join_background_db_threads", lambda: None)()
conn.close()
sys.modules.pop("server", None)
+1
View File
@@ -21,6 +21,7 @@ def server(tmp_path, monkeypatch, isolate_logging):
finally:
conn = getattr(getattr(srv, "meta_db", None), "conn", None)
if conn is not None:
getattr(__import__("sys").modules.get("server"), "_join_background_db_threads", lambda: None)()
conn.close()
sys.modules.pop("server", None)
+1
View File
@@ -73,6 +73,7 @@ def server(tmp_path, monkeypatch, isolate_logging):
finally:
conn = getattr(getattr(srv, "meta_db", None), "conn", None)
if conn is not None:
getattr(__import__("sys").modules.get("server"), "_join_background_db_threads", lambda: None)()
conn.close()
sys.modules.pop("server", None)
+1
View File
@@ -30,6 +30,7 @@ def server(tmp_path, monkeypatch, isolate_logging):
finally:
conn = getattr(getattr(srv, "meta_db", None), "conn", None)
if conn is not None:
getattr(__import__("sys").modules.get("server"), "_join_background_db_threads", lambda: None)()
conn.close()
sys.modules.pop("server", None)
+124 -2
View File
@@ -75,6 +75,7 @@ def client(tmp_path, monkeypatch):
meta_db = getattr(server, "meta_db", None)
conn = getattr(meta_db, "conn", None)
if conn is not None:
getattr(__import__("sys").modules.get("server"), "_join_background_db_threads", lambda: None)()
conn.close()
@@ -296,6 +297,7 @@ def server_module(tmp_path, monkeypatch):
meta_db = getattr(mod, "meta_db", None)
conn = getattr(meta_db, "conn", None)
if conn is not None:
getattr(__import__("sys").modules.get("server"), "_join_background_db_threads", lambda: None)()
conn.close()
@@ -324,6 +326,7 @@ def test_get_dlc_dir_uses_config_when_env_empty(tmp_path, monkeypatch):
finally:
conn = getattr(getattr(mod, "meta_db", None), "conn", None)
if conn is not None:
getattr(__import__("sys").modules.get("server"), "_join_background_db_threads", lambda: None)()
conn.close()
@@ -345,6 +348,7 @@ def test_get_dlc_dir_env_takes_precedence(tmp_path, monkeypatch):
finally:
conn = getattr(getattr(mod, "meta_db", None), "conn", None)
if conn is not None:
getattr(__import__("sys").modules.get("server"), "_join_background_db_threads", lambda: None)()
conn.close()
@@ -362,6 +366,7 @@ def test_get_dlc_dir_env_dot_is_valid(tmp_path, monkeypatch):
finally:
conn = getattr(getattr(mod, "meta_db", None), "conn", None)
if conn is not None:
getattr(__import__("sys").modules.get("server"), "_join_background_db_threads", lambda: None)()
conn.close()
@@ -404,6 +409,7 @@ def scan_module(tmp_path, monkeypatch, isolate_logging):
yield mod
conn = getattr(getattr(mod, "meta_db", None), "conn", None)
if conn is not None:
getattr(__import__("sys").modules.get("server"), "_join_background_db_threads", lambda: None)()
conn.close()
@@ -525,6 +531,7 @@ def api_client(tmp_path, monkeypatch, isolate_logging):
finally:
conn = getattr(getattr(server, "meta_db", None), "conn", None)
if conn is not None:
getattr(__import__("sys").modules.get("server"), "_join_background_db_threads", lambda: None)()
conn.close()
_restore_loaded_plugins(plugins_snapshot)
@@ -653,6 +660,7 @@ def test_skip_startup_tasks_does_not_call_load_plugins_or_scan(tmp_path, monkeyp
finally:
conn = getattr(getattr(server, "meta_db", None), "conn", None)
if conn is not None:
getattr(__import__("sys").modules.get("server"), "_join_background_db_threads", lambda: None)()
conn.close()
_restore_loaded_plugins(plugins_snapshot)
@@ -698,6 +706,7 @@ def test_skip_startup_tasks_clears_stale_plugin_registry(tmp_path, monkeypatch,
finally:
conn = getattr(getattr(server, "meta_db", None), "conn", None) if server else None
if conn is not None:
getattr(__import__("sys").modules.get("server"), "_join_background_db_threads", lambda: None)()
conn.close()
_restore_loaded_plugins(plugins_snapshot)
@@ -744,29 +753,142 @@ def test_defaults_include_gameplay_keys(client, tmp_path):
assert data["fail_behavior"] == "continue"
def test_get_settings_exposes_default_instrument_profiles(client, tmp_path):
data = client.get("/api/settings").json()
assert data["active_instrument_profile"] == "guitar-lead"
assert set(data["instrument_profiles"]) == {"guitar-lead", "guitar-rhythm", "bass"}
assert data["instrument"] == "guitar"
assert data["string_count"] == 6
assert data["tuning"] == "Standard"
assert data["pathway"] == "songs"
def test_post_flat_instrument_updates_active_profile(client, tmp_path):
r = client.post("/api/settings", json={"instrument": "bass", "pathway": "practice"})
assert r.status_code == 200
cfg = _read_cfg(tmp_path)
assert cfg["active_instrument_profile"] == "bass"
assert cfg["instrument"] == "bass"
assert cfg["string_count"] == 4
assert cfg["tuning"] == "Standard"
assert cfg["pathway"] == "practice"
assert cfg["instrument_profiles"]["bass"]["string_count"] == 4
assert cfg["instrument_profiles"]["bass"]["pathway"] == "practice"
def test_post_instrument_profiles_mirrors_active_profile(client, tmp_path):
r = client.post("/api/settings", json={
"active_instrument_profile": "guitar-rhythm",
"instrument_profiles": {
"guitar-rhythm": {
"string_count": 7,
"tuning": "Drop A",
"reference_pitch": 432,
"pathway": "studio",
},
"bass": {
"string_count": 6,
"tuning": "C Standard",
},
},
})
assert r.status_code == 200
cfg = _read_cfg(tmp_path)
assert cfg["active_instrument_profile"] == "guitar-rhythm"
assert cfg["instrument"] == "guitar"
assert cfg["string_count"] == 7
assert cfg["tuning"] == "Drop A"
assert cfg["reference_pitch"] == 432
assert cfg["pathway"] == "studio"
def test_post_pathway_rejects_bad_value(client, tmp_path):
(tmp_path / "config.json").write_text(json.dumps({"pathway": "songs"}))
r = client.post("/api/settings", json={"pathway": "invalid"})
assert "error" in r.json()
assert _read_cfg(tmp_path)["pathway"] == "songs"
def test_post_instrument_profiles_rejects_bad_custom_string_count(client, tmp_path):
r = client.post("/api/settings", json={
"instrument_profiles": {
"bass": {"string_count": 6, "tuning": [0, 0, 0, 0]},
},
})
assert "error" in r.json()
# ── /api/settings/reset ─────────────────────────────────────────────────────
def test_reset_clears_requested_keys(client, tmp_path):
(tmp_path / "config.json").write_text(json.dumps({
"master_difficulty": 40,
"countdown_before_song": True,
"pathway": "studio",
"default_arrangement": "Lead",
"demucs_server_url": "http://demucs.example:9000",
}))
r = client.post("/api/settings/reset",
json={"keys": ["master_difficulty", "countdown_before_song"]})
json={"keys": ["master_difficulty", "countdown_before_song", "pathway"]})
assert r.status_code == 200
body = r.json()
assert set(body["reset"]) == {"master_difficulty", "countdown_before_song"}
assert set(body["reset"]) == {"master_difficulty", "countdown_before_song", "pathway"}
cfg = _read_cfg(tmp_path)
# Reset removes the key so GET falls back to the default.
assert "master_difficulty" not in cfg
assert "countdown_before_song" not in cfg
assert "pathway" not in cfg
# Unlisted keys are untouched.
assert cfg["default_arrangement"] == "Lead"
assert cfg["demucs_server_url"] == "http://demucs.example:9000"
def test_partial_instrument_profiles_update_preserves_others(client, tmp_path):
# /api/settings is a partial-merge endpoint, so a POST that carries only ONE
# instrument profile must not reset the others to defaults.
gl = client.get("/api/settings").json()["instrument_profiles"]["guitar-lead"]
gl = dict(gl); gl["tuning"] = "Drop D"
client.post("/api/settings", json={"instrument_profiles": {"guitar-lead": gl}})
assert (client.get("/api/settings").json()["instrument_profiles"]
["guitar-lead"]["tuning"] == "Drop D")
# Now update ONLY bass (Drop D is valid for a 4-string bass).
bass = client.get("/api/settings").json()["instrument_profiles"]["bass"]
bass = dict(bass); bass["tuning"] = "Drop D"
client.post("/api/settings", json={"instrument_profiles": {"bass": bass}})
out = client.get("/api/settings").json()["instrument_profiles"]
assert out["guitar-lead"]["tuning"] == "Drop D", "the untouched profile survived"
assert out["bass"]["tuning"] == "Drop D"
def test_active_profile_switch_on_fresh_config(client, tmp_path):
# A fresh config has no instrument_profiles; an explicit active-profile
# switch must be honored, not overwritten by the profile inferred from the
# legacy flat defaults (guitar-lead).
r = client.post("/api/settings", json={"active_instrument_profile": "bass"})
assert r.status_code == 200 and "error" not in r.json()
got = client.get("/api/settings").json()
assert got["active_instrument_profile"] == "bass"
assert got["instrument"] == "bass"
def test_reset_pathway_reaches_into_instrument_profiles(client, tmp_path):
# pathway is mirrored into every instrument profile, so a Gameplay reset
# that only deleted the flat key would leave GET re-deriving the old value
# from the profile. The reset must reach into the persisted profiles too.
client.post("/api/settings", json={"pathway": "studio"})
assert client.get("/api/settings").json()["pathway"] == "studio"
profiles = _read_cfg(tmp_path)["instrument_profiles"]
assert any(p["pathway"] == "studio" for p in profiles.values())
r = client.post("/api/settings/reset", json={"keys": ["pathway"]})
assert r.status_code == 200
assert "pathway" in r.json()["reset"]
# GET re-derives from the profile — which must now be back to the default.
assert client.get("/api/settings").json()["pathway"] == "songs"
for prof in _read_cfg(tmp_path)["instrument_profiles"].values():
assert prof["pathway"] == "songs"
def test_reset_ignores_unknown_keys(client, tmp_path):
(tmp_path / "config.json").write_text(json.dumps({"master_difficulty": 40}))
# Unknown / non-resettable keys are silently ignored, not an error, and
+1
View File
@@ -28,6 +28,7 @@ def server_mod(tmp_path, monkeypatch):
yield mod
conn = getattr(getattr(mod, "meta_db", None), "conn", None)
if conn is not None:
getattr(__import__("sys").modules.get("server"), "_join_background_db_threads", lambda: None)()
conn.close()
+3
View File
@@ -31,6 +31,7 @@ def server_mod(tmp_path, monkeypatch):
yield mod
conn = getattr(getattr(mod, "meta_db", None), "conn", None)
if conn is not None:
getattr(__import__("sys").modules.get("server"), "_join_background_db_threads", lambda: None)()
conn.close()
@@ -87,6 +88,7 @@ def test_export_includes_consistent_library_db_snapshot(client, server_mod, tmp_
"SELECT title FROM songs WHERE filename = ?", ("snap.archive",)
).fetchall()
finally:
getattr(__import__("sys").modules.get("server"), "_join_background_db_threads", lambda: None)()
conn.close()
assert rows == [("SnapSong",)]
@@ -271,6 +273,7 @@ def test_full_db_backup_restore_round_trip(client, server_mod, tmp_path):
"SELECT title FROM songs WHERE filename = ?", ("keepme.archive",)
).fetchall()
finally:
getattr(__import__("sys").modules.get("server"), "_join_background_db_threads", lambda: None)()
conn.close()
assert rows == [("KeepMe",)]
assert not (tmp_path / "web_library.db.restore").exists()
+5 -2
View File
@@ -19,6 +19,7 @@ def env(tmp_path, monkeypatch, isolate_logging):
finally:
conn = getattr(getattr(srv, "meta_db", None), "conn", None)
if conn is not None:
getattr(__import__("sys").modules.get("server"), "_join_background_db_threads", lambda: None)()
conn.close()
sys.modules.pop("server", None)
@@ -30,12 +31,14 @@ def _cfg(tmp_path):
def test_instrument_fields_persist(env):
srv, tmp = env
c = TestClient(srv.app)
# "Drop A" is the 5-string bass drop tuning (its low string is B, not E, so
# "Drop D" is a 4-string tuning — now correctly rejected per-profile).
r = c.post("/api/settings", json={"instrument": "bass", "string_count": 5,
"tuning": "Drop D", "reference_pitch": 442})
"tuning": "Drop A", "reference_pitch": 442})
assert r.status_code == 200
cfg = _cfg(tmp)
assert cfg["instrument"] == "bass" and cfg["string_count"] == 5
assert cfg["tuning"] == "Drop D" and cfg["reference_pitch"] == 442.0
assert cfg["tuning"] == "Drop A" and cfg["reference_pitch"] == 442.0
# Reflected back through GET.
got = c.get("/api/settings").json()
assert got["instrument"] == "bass" and got["reference_pitch"] == 442.0
+1
View File
@@ -116,6 +116,7 @@ def dlc_client(tmp_path, monkeypatch):
meta_db = getattr(server, "meta_db", None)
conn = getattr(meta_db, "conn", None)
if conn is not None:
getattr(__import__("sys").modules.get("server"), "_join_background_db_threads", lambda: None)()
conn.close()
+1
View File
@@ -45,6 +45,7 @@ def dlc_client(tmp_path, monkeypatch):
meta_db = getattr(server, "meta_db", None)
conn = getattr(meta_db, "conn", None)
if conn is not None:
getattr(__import__("sys").modules.get("server"), "_join_background_db_threads", lambda: None)()
conn.close()
+1
View File
@@ -18,6 +18,7 @@ def server(tmp_path, monkeypatch, isolate_logging):
finally:
conn = getattr(getattr(srv, "meta_db", None), "conn", None)
if conn is not None:
getattr(__import__("sys").modules.get("server"), "_join_background_db_threads", lambda: None)()
conn.close()
sys.modules.pop("server", None)
+1
View File
@@ -21,6 +21,7 @@ def server(tmp_path, monkeypatch, isolate_logging):
finally:
conn = getattr(getattr(srv, "meta_db", None), "conn", None)
if conn is not None:
getattr(__import__("sys").modules.get("server"), "_join_background_db_threads", lambda: None)()
conn.close()
sys.modules.pop("server", None)
+120
View File
@@ -0,0 +1,120 @@
"""Tests for the 'Start here' starter shelf (launch polish) —
GET /api/library/practice-suggestions when NO practice attempts exist.
growth_edge_suggestions returns starter picks (sensible-length songs,
shortest first, flagged starter:true) only on a never-practiced library;
the moment any scored attempt exists the normal growth-edge behaviour is
unchanged — including the honest empty shelf when everything attempted is
mastered. Read-only, like the recommender it falls back from."""
import importlib
import sys
import pytest
from fastapi.testclient import TestClient
@pytest.fixture()
def server(tmp_path, monkeypatch, isolate_logging):
monkeypatch.setenv("CONFIG_DIR", str(tmp_path))
monkeypatch.setenv("FEEDBACK_SKIP_STARTUP_TASKS", "1")
sys.modules.pop("server", None)
srv = importlib.import_module("server")
try:
yield srv
finally:
conn = getattr(getattr(srv, "meta_db", None), "conn", None)
if conn is not None:
getattr(__import__("sys").modules.get("server"), "_join_background_db_threads", lambda: None)()
conn.close()
sys.modules.pop("server", None)
@pytest.fixture()
def client(server):
return TestClient(server.app)
def _seed(server, fn, duration, title=None):
server.meta_db.put(fn, 0, 0, {
"title": title or fn.split(".")[0], "artist": "A", "duration": duration})
def _play(server, fn, acc, arr=0):
"""Record a scored attempt so the song has a best_accuracy."""
server.meta_db.record_session(fn, arr, score=int(acc * 1000), accuracy=acc)
def _suggest(client, limit=8):
return client.get(f"/api/library/practice-suggestions?limit={limit}").json()
# ── No attempts → starter picks, shortest sensible first ─────────────────────
def test_no_attempts_returns_starter_rows(client, server):
_seed(server, "long.archive", 600) # > 480s → not a starter
_seed(server, "jingle.archive", 30) # < 90s → not a starter
_seed(server, "mid.archive", 200)
_seed(server, "short.archive", 120)
rows = _suggest(client)
assert [r["filename"] for r in rows] == ["short.archive", "mid.archive"]
assert all(r["starter"] is True for r in rows)
def test_starter_duration_bounds_inclusive(client, server):
_seed(server, "at90.archive", 90)
_seed(server, "at480.archive", 480)
_seed(server, "under.archive", 89)
_seed(server, "over.archive", 481)
_seed(server, "nodur.archive", 0) # unknown length → never a starter
got = {r["filename"] for r in _suggest(client)}
assert got == {"at90.archive", "at480.archive"}
def test_starter_caps_at_eight(client, server):
for i in range(10):
_seed(server, f"s{i:02d}.archive", 100 + i)
assert len(_suggest(client)) == 8
# Even an explicit larger limit never exceeds the starter cap of 8.
assert len(_suggest(client, limit=20)) == 8
def test_starter_rows_are_enriched_and_growth_shaped(client, server):
"""Same row shape as the growth-edge rows (the client reuses the card
markup verbatim) plus the starter marker; enriched by the route."""
_seed(server, "song.archive", 150, title="My Song")
r = _suggest(client)[0]
assert r["starter"] is True
assert r["title"] == "My Song" and r["artist"] == "A"
assert r["art_url"].endswith("/art")
for key in ("filename", "best_accuracy", "arrangement", "last_played_at",
"user_difficulty", "growth_score"):
assert key in r
# No attempt yet → no accuracy/arrangement; the client passes an
# undefined arrangement so playSong picks the default.
assert r["best_accuracy"] is None
assert r["arrangement"] is None
# ── Attempts exist → normal growth-edge behaviour, unchanged ─────────────────
def test_attempts_exist_normal_behaviour_unchanged(client, server):
_seed(server, "inprog.archive", 150)
_seed(server, "fresh.archive", 150)
_play(server, "inprog.archive", 0.6)
rows = _suggest(client)
assert [r["filename"] for r in rows] == ["inprog.archive"]
assert not any(r.get("starter") for r in rows)
def test_all_mastered_returns_empty_not_starter(client, server):
"""Attempts exist and everything attempted is mastered → the shelf is
honestly empty; the starter fallback must NOT kick in."""
_seed(server, "done.archive", 150)
_seed(server, "fresh.archive", 150)
_play(server, "done.archive", 0.95)
assert _suggest(client) == []
def test_empty_library_returns_empty(client, server):
assert _suggest(client) == []
+5
View File
@@ -85,6 +85,7 @@ def client(tmp_path, monkeypatch, isolate_logging):
finally:
conn = getattr(getattr(server, "meta_db", None), "conn", None)
if conn is not None:
getattr(__import__("sys").modules.get("server"), "_join_background_db_threads", lambda: None)()
conn.close()
@@ -127,6 +128,7 @@ def startup_harness(tmp_path, monkeypatch, isolate_logging):
server._DEMO_JANITOR_THREAD = None
conn = getattr(getattr(server, "meta_db", None), "conn", None)
if conn is not None:
getattr(__import__("sys").modules.get("server"), "_join_background_db_threads", lambda: None)()
conn.close()
@@ -692,6 +694,7 @@ def test_startup_status_e2e_real_plugin_loader(tmp_path, monkeypatch, isolate_lo
server._DEMO_JANITOR_THREAD = None
conn = getattr(getattr(server, "meta_db", None), "conn", None)
if conn is not None:
getattr(__import__("sys").modules.get("server"), "_join_background_db_threads", lambda: None)()
conn.close()
with plugins_mod.PLUGINS_LOCK:
plugins_mod.LOADED_PLUGINS.clear()
@@ -782,6 +785,7 @@ def test_startup_status_endpoint_background_thread_path(tmp_path, monkeypatch, i
server._DEMO_JANITOR_THREAD = None
conn = getattr(getattr(server, "meta_db", None), "conn", None)
if conn is not None:
getattr(__import__("sys").modules.get("server"), "_join_background_db_threads", lambda: None)()
conn.close()
@@ -830,6 +834,7 @@ def test_startup_status_endpoint_background_thread_failure(tmp_path, monkeypatch
server._DEMO_JANITOR_THREAD = None
conn = getattr(getattr(server, "meta_db", None), "conn", None)
if conn is not None:
getattr(__import__("sys").modules.get("server"), "_join_background_db_threads", lambda: None)()
conn.close()
+118 -1
View File
@@ -2,7 +2,31 @@
import pytest
from tunings import tuning_name
from tunings import (
DEFAULT_TUNINGS,
TUNING_PRESET_MIDIS,
_valid_tuning_for_key,
apply_flat_instrument_patch_to_profiles,
open_midis_to_freqs,
settings_with_instrument_profiles,
tuning_midis_from_offsets,
tuning_name,
tuning_offsets_from_midis,
tuning_preset_offsets,
)
def test_valid_tuning_for_key_builtin_and_provider_names():
# A built-in valid for the key is accepted; a built-in valid only for a
# DIFFERENT key (misapplied, e.g. "Drop D" on a 5-string bass) is rejected.
assert _valid_tuning_for_key("bass-5", "Drop A") == "Drop A"
assert _valid_tuning_for_key("bass-5", "Drop D") is None
assert _valid_tuning_for_key("guitar-6", "Standard") == "Standard"
# A name unknown to every built-in table is a provider/custom tuning (tuner
# plugin, /api/tunings) the pure layer can't resolve — accept it so settings
# round-trip rather than normalizing it away to Standard.
assert _valid_tuning_for_key("bass-5", "My Custom DADGAD") == "My Custom DADGAD"
assert _valid_tuning_for_key("guitar-6", "x" * 65) is None # length cap kept
# ── Standard tunings (all six strings share the same offset) ─────────────────
@@ -132,3 +156,96 @@ def test_drop_pattern_takes_precedence_over_named_dict():
# auto-generator fires first and produces the same string. The named dict entry
# is effectively dead code for this case — this test documents the behavior.
assert tuning_name([-2, 0, 0, 0, 0, 0]) == "Drop D"
# ── Host tuning profile catalogue -------------------------------------------
def test_default_tunings_include_extended_host_profiles():
assert "bass-6" in DEFAULT_TUNINGS
assert "C Standard" in DEFAULT_TUNINGS["guitar-6"]
assert "C# Standard" in DEFAULT_TUNINGS["guitar-6"]
assert "Drop Ab" in DEFAULT_TUNINGS["guitar-6"]
assert "BEAD" in DEFAULT_TUNINGS["bass-4"]
assert "High C" in DEFAULT_TUNINGS["bass-5"]
assert "Drop A + Drop E" in DEFAULT_TUNINGS["guitar-8"]
def test_default_tuning_frequencies_are_derived_from_midis():
assert DEFAULT_TUNINGS["guitar-6"]["Standard"] == open_midis_to_freqs([40, 45, 50, 55, 59, 64])
assert DEFAULT_TUNINGS["bass-6"]["Standard"] == open_midis_to_freqs([23, 28, 33, 38, 43, 48])
def test_tuning_offsets_from_named_presets():
assert tuning_preset_offsets("guitar-6", "Drop D") == [-2, 0, 0, 0, 0, 0]
assert tuning_preset_offsets("guitar-6", "C Standard") == [-4, -4, -4, -4, -4, -4]
assert tuning_preset_offsets("bass-4", "BEAD") == [-5, -5, -5, -5]
assert tuning_preset_offsets("bass-5", "High C") == [5, 5, 5, 5, 5]
def test_tuning_midis_round_trip_offsets():
offsets = [-2, 0, 0, 0, 0, 0]
midis = tuning_midis_from_offsets("guitar-6", offsets)
assert midis == TUNING_PRESET_MIDIS["guitar-6"]["Drop D"]
assert tuning_offsets_from_midis("guitar-6", midis) == offsets
def test_tuning_conversion_rejects_wrong_string_count():
assert tuning_offsets_from_midis("guitar-6", [40, 45, 50, 55]) is None
assert tuning_midis_from_offsets("bass-4", [0, 0, 0, 0, 0]) is None
def test_settings_profiles_default_to_lead_rhythm_and_bass():
settings = settings_with_instrument_profiles({})
assert settings["active_instrument_profile"] == "guitar-lead"
assert set(settings["instrument_profiles"]) == {"guitar-lead", "guitar-rhythm", "bass"}
assert settings["instrument"] == "guitar"
assert settings["string_count"] == 6
assert settings["tuning"] == "Standard"
assert settings["pathway"] == "songs"
assert settings["instrument_profiles"]["guitar-lead"]["pathway"] == "songs"
def test_settings_profiles_migrate_legacy_flat_bass_selection():
settings = settings_with_instrument_profiles({
"instrument": "bass",
"string_count": 6,
"tuning": "C Standard",
"reference_pitch": 432,
"pathway": "practice",
})
assert settings["active_instrument_profile"] == "bass"
assert settings["instrument_profiles"]["bass"]["string_count"] == 6
assert settings["instrument_profiles"]["bass"]["tuning"] == "C Standard"
assert settings["reference_pitch"] == 432
assert settings["pathway"] == "practice"
assert settings["instrument_profiles"]["bass"]["pathway"] == "practice"
def test_flat_patch_updates_active_profile_and_mirrors_legacy_keys():
settings = settings_with_instrument_profiles({})
patched = apply_flat_instrument_patch_to_profiles(settings, {"tuning": "Drop D"})
assert patched["tuning"] == "Drop D"
assert patched["instrument_profiles"]["guitar-lead"]["tuning"] == "Drop D"
def test_flat_pathway_patch_updates_active_profile_and_mirrors_legacy_key():
settings = settings_with_instrument_profiles({})
patched = apply_flat_instrument_patch_to_profiles(settings, {"pathway": "studio"})
assert patched["pathway"] == "studio"
assert patched["instrument_profiles"]["guitar-lead"]["pathway"] == "studio"
def test_flat_instrument_patch_defaults_to_target_string_count():
settings = settings_with_instrument_profiles({"instrument": "guitar", "string_count": 6, "tuning": "Drop D"})
patched = apply_flat_instrument_patch_to_profiles(settings, {"instrument": "bass"})
assert patched["instrument"] == "bass"
assert patched["string_count"] == 4
assert patched["tuning"] == "Standard"
assert patched["active_instrument_profile"] == "bass"
assert patched["instrument_profiles"]["bass"]["string_count"] == 4
def test_flat_string_count_patch_resets_incompatible_named_tuning():
settings = settings_with_instrument_profiles({"instrument": "guitar", "string_count": 6, "tuning": "DADGAD"})
patched = apply_flat_instrument_patch_to_profiles(settings, {"string_count": 7})
assert patched["string_count"] == 7
assert patched["tuning"] == "Standard"
+1
View File
@@ -44,6 +44,7 @@ def client(tmp_path, monkeypatch):
finally:
conn = getattr(getattr(server, "meta_db", None), "conn", None)
if conn is not None:
getattr(__import__("sys").modules.get("server"), "_join_background_db_threads", lambda: None)()
conn.close()
+1
View File
@@ -22,6 +22,7 @@ def server_mod(tmp_path, monkeypatch):
yield mod
conn = getattr(getattr(mod, "meta_db", None), "conn", None)
if conn is not None:
getattr(__import__("sys").modules.get("server"), "_join_background_db_threads", lambda: None)()
conn.close()
+1
View File
@@ -20,6 +20,7 @@ def server(tmp_path, monkeypatch, isolate_logging):
finally:
conn = getattr(getattr(srv, "meta_db", None), "conn", None)
if conn is not None:
getattr(__import__("sys").modules.get("server"), "_join_background_db_threads", lambda: None)()
conn.close()
sys.modules.pop("server", None)
+1
View File
@@ -22,6 +22,7 @@ def server(tmp_path, monkeypatch, isolate_logging):
finally:
conn = getattr(getattr(srv, "meta_db", None), "conn", None)
if conn is not None:
getattr(__import__("sys").modules.get("server"), "_join_background_db_threads", lambda: None)()
conn.close()
sys.modules.pop("server", None)