mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-10-03 19:47:51 +00:00
Merge remote-tracking branch 'origin/main' into fix/v3-library-scroll-recycle
# Conflicts: # CHANGELOG.md
This commit is contained in:
@@ -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', () => {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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(')}
|
||||
|
||||
@@ -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]);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
|
||||
@@ -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' });
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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")
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)}"
|
||||
)
|
||||
@@ -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()
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,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
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
|
||||
@@ -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
@@ -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"] = [
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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) == []
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user