mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-08-11 19:29:33 +00:00
Purge external-format terminology from code, tests and docs
Reword comments/docstrings/strings and rename identifiers that referenced the external game and its file formats: - format-id "psarc" -> "archive"; local vars psarc_path -> song_path, psarc_base -> tone_base - lyrics provenance value "sng" -> "notechart" (legacy "sng" still accepted) - highway_3d fret-ghost scope value "rocksmith" -> "chords" (invalid/legacy values fall back to the default, preserving behaviour) - neutralise references in prose, test names/data, .gitattributes and docs No functional change beyond the renamed identifiers; all Python compiles.
This commit is contained in:
@@ -70,7 +70,7 @@ test('player arrangement pin saves the selected arrangement name', async ({ page
|
||||
select.value = '2';
|
||||
// @ts-ignore - browser app namespace
|
||||
window.slopsmith.currentSong = {
|
||||
filename: 'demo.psarc',
|
||||
filename: 'demo.archive',
|
||||
arrangement: 'Rhythm',
|
||||
arrangementIndex: 2,
|
||||
arrangements,
|
||||
@@ -138,7 +138,7 @@ test('player arrangement pin preserves non-built-in arrangement names', async ({
|
||||
select.value = '2';
|
||||
// @ts-ignore - browser app namespace
|
||||
window.slopsmith.currentSong = {
|
||||
filename: 'demo.psarc',
|
||||
filename: 'demo.archive',
|
||||
arrangement: 'Rhythm',
|
||||
arrangementIndex: 2,
|
||||
arrangements,
|
||||
@@ -192,7 +192,7 @@ test('failed settings save does not mark arrangement default as persisted', asyn
|
||||
select.value = '2';
|
||||
// @ts-ignore - browser app namespace
|
||||
window.slopsmith.currentSong = {
|
||||
filename: 'demo.psarc',
|
||||
filename: 'demo.archive',
|
||||
arrangement: 'Rhythm',
|
||||
arrangementIndex: 2,
|
||||
arrangements,
|
||||
|
||||
@@ -817,7 +817,7 @@ test('mapping helpers call core mapping API with provider-tagged payloads', asyn
|
||||
|
||||
const saved = await window.slopsmith.audioEffects.upsertMapping({
|
||||
song_key: 'settings-v1-song',
|
||||
filename: 'Artist - Song_p.psarc',
|
||||
filename: 'Artist - Song_p.archive',
|
||||
tone_key: 'Dist',
|
||||
provider_id: 'rig-builder',
|
||||
provider_ref: 'chain:99',
|
||||
@@ -880,7 +880,7 @@ test('bridge hits are safe and diagnosable', async () => {
|
||||
routeKey: 'desktop-main',
|
||||
bridgeId: 'audio-effects.legacy-nam-routing',
|
||||
pluginId: 'rig_builder',
|
||||
legacySurface: 'fetch /Users/example/song.psarc token=abc123',
|
||||
legacySurface: 'fetch /Users/example/song.archive token=abc123',
|
||||
},
|
||||
});
|
||||
const dbResult = await api.dispatch({
|
||||
|
||||
@@ -22,13 +22,13 @@ test('audio session lifecycle and snapshots redact source identity with per-snap
|
||||
const window = loadAudioSession();
|
||||
const audioSession = window.slopsmith.audioSession;
|
||||
|
||||
audioSession.startSession({ sessionId: 'main:/Users/example/DLC/song.psarc', songKey: '/Users/example/DLC/song.psarc', songFormat: 'psarc' });
|
||||
audioSession.startSession({ sessionId: 'main:/Users/example/DLC/song.archive', songKey: '/Users/example/DLC/song.archive', songFormat: 'archive' });
|
||||
audioSession.setRoute({ routeKind: 'html5', availability: 'available', deviceLabel: 'Scarlett 2i2 Serial 1234' });
|
||||
audioSession.registerInputSource({ sourceId: 'mic-raw-id', logicalSourceKey: 'browser:instrument:primary', providerId: 'browser', kind: 'instrument', channelCount: 2, availability: 'available', label: 'Scarlett 2i2 Serial 1234' });
|
||||
|
||||
const snapshot = audioSession.snapshot();
|
||||
const encoded = JSON.stringify(snapshot);
|
||||
assert.equal(snapshot.session.songFormat, 'psarc');
|
||||
assert.equal(snapshot.session.songFormat, 'archive');
|
||||
assert.match(snapshot.domains['audio-input'].sources[0].diagnosticsPseudonym, /^source-\d{2}$/);
|
||||
assert.equal(encoded.includes('Scarlett'), false);
|
||||
assert.equal(encoded.includes('/Users/example'), false);
|
||||
@@ -156,7 +156,7 @@ test('audio-mix diagnostics include faders routes analysers bridge hits and reda
|
||||
const window = loadAudioSession();
|
||||
const api = window.slopsmith.capabilities;
|
||||
const audioSession = window.slopsmith.audioSession;
|
||||
audioSession.startSession({ sessionId: 'main:/Users/example/DLC/song.psarc', songKey: '/Users/example/DLC/song.psarc' });
|
||||
audioSession.startSession({ sessionId: 'main:/Users/example/DLC/song.archive', songKey: '/Users/example/DLC/song.archive' });
|
||||
audioSession.setRoute({ routeKind: 'desktop', availability: 'degraded', deviceLabel: 'Secret Studio Output', fallbackReason: 'fallback token=abc123 at /Users/example/device' });
|
||||
audioSession.setAnalyser({ source: 'plugin', availability: 'available', participantId: 'plugin.visualizer', reason: 'ok', rawFft: [1, 2, 3] });
|
||||
audioSession.recordBridgeHit({ domain: 'audio-mix', bridgeId: 'audio-mix.fader-registry', legacySurface: 'registerFader', participantId: 'legacy.delay', outcome: 'failed', reason: 'password=abc path /Users/example/plugin' });
|
||||
|
||||
@@ -205,7 +205,7 @@ test('re-registering a mix participant without handlers preserves the existing s
|
||||
// every song load and re-registers core.song WITHOUT get/set handlers.
|
||||
// registerMixParticipant replaces the participant, so before the fix this
|
||||
// wiped the fader.set-value handler installed at init — the mixer slider
|
||||
// then moved visually but never applied the volume (PSARC and sloppak).
|
||||
// then moved visually but never applied the volume (archive and sloppak).
|
||||
const window = loadAudioSession();
|
||||
const audioSession = window.slopsmith.audioSession;
|
||||
audioSession.startSession({ sessionId: 'main:test-song', songKey: 'test-song', songFormat: 'sloppak' });
|
||||
|
||||
@@ -111,10 +111,10 @@ test('audio-input selection and registered providers survive song session switch
|
||||
const open = await api.dispatch({ capability: 'audio-input', command: 'open-source', source: 'note_detect', payload: { requesterId: 'note_detect', requiredChannelShape: 'mono' } });
|
||||
assert.equal(open.outcome, 'handled');
|
||||
|
||||
const next = audioSession.startSession({ sessionId: 'main:second-song', songKey: 'second-song.psarc', songFormat: 'psarc' });
|
||||
const next = audioSession.startSession({ sessionId: 'main:second-song', songKey: 'second-song.archive', songFormat: 'archive' });
|
||||
const listed = await api.dispatch({ capability: 'audio-input', command: 'list-sources', source: 'note_detect' });
|
||||
|
||||
assert.equal(next.session.songFormat, 'psarc');
|
||||
assert.equal(next.session.songFormat, 'archive');
|
||||
assert.equal(next.domains['audio-input'].selected.logicalSourceKey, 'switch:instrument:primary');
|
||||
assert.equal(next.domains['audio-input'].totalOpenSessions, 0);
|
||||
assert.equal(listed.payload.sources.some(source => source.logicalSourceKey === 'switch:instrument:primary'), true);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// Pins the fret-spacing setting in plugins/highway_3d/screen.js (PR #329).
|
||||
// The board can render fret columns either Uniform (equal width, Rocksmith
|
||||
// The board can render fret columns either Uniform (equal width, the source game
|
||||
// Remastered style) or Logarithmic (real instrument geometry), switchable at
|
||||
// runtime via window.h3dSetFretSpacing and persisted in localStorage. A
|
||||
// refactor that renames the storage key, drops the uniform/log branch in
|
||||
|
||||
@@ -9,14 +9,14 @@ test('register + list returns applicable actions sorted by order', () => {
|
||||
freshIds();
|
||||
reg.register({ id: 'b', label: 'B', order: 20, run() {} });
|
||||
reg.register({ id: 'a', label: 'A', order: 10, run() {} });
|
||||
const ids = reg.list({ filename: 'x.psarc' }).map((a) => a.id);
|
||||
const ids = reg.list({ filename: 'x.archive' }).map((a) => a.id);
|
||||
assert.deepStrictEqual(ids, ['a', 'b']);
|
||||
});
|
||||
|
||||
test('applies() filters out non-applicable actions', () => {
|
||||
freshIds();
|
||||
reg.register({ id: 'bassonly', label: 'Bass', applies: (s) => s.format === 'sloppak', run() {} });
|
||||
assert.strictEqual(reg.list({ filename: 'x.psarc', format: 'psarc' }).length, 0);
|
||||
assert.strictEqual(reg.list({ filename: 'x.archive', format: 'archive' }).length, 0);
|
||||
assert.strictEqual(reg.list({ filename: 'y.sloppak', format: 'sloppak' }).length, 1);
|
||||
});
|
||||
|
||||
@@ -33,10 +33,10 @@ test('run() invokes the handler and reports handled', async () => {
|
||||
freshIds();
|
||||
let got = null;
|
||||
reg.register({ id: 'go', label: 'Go', run: (song) => { got = song.filename; return 'done'; } });
|
||||
const r = await reg.run('go', { filename: 'song.psarc' }, {});
|
||||
const r = await reg.run('go', { filename: 'song.archive' }, {});
|
||||
assert.strictEqual(r.ok, true);
|
||||
assert.strictEqual(r.outcome, 'handled');
|
||||
assert.strictEqual(got, 'song.psarc');
|
||||
assert.strictEqual(got, 'song.archive');
|
||||
});
|
||||
|
||||
test('run() of a disabled / non-applicable / unknown action does not throw', async () => {
|
||||
|
||||
@@ -49,7 +49,7 @@ test('reset counters via bindRuntime song lifecycle', () => {
|
||||
};
|
||||
|
||||
const runtime = hud.bindRuntime(sm);
|
||||
sm.emit('song:loading', { filename: 'song.psarc' });
|
||||
sm.emit('song:loading', { filename: 'song.archive' });
|
||||
assert.equal(runtime.isActive(), true);
|
||||
|
||||
runtime.onHit();
|
||||
@@ -59,7 +59,7 @@ test('reset counters via bindRuntime song lifecycle', () => {
|
||||
assert.equal(runtime.getCounters().misses, 1);
|
||||
assert.equal(runtime.getCounters().streak, 0);
|
||||
|
||||
sm.emit('song:arrangement-changed', { filename: 'song.psarc', arrangement: 1 });
|
||||
sm.emit('song:arrangement-changed', { filename: 'song.archive', arrangement: 1 });
|
||||
assert.deepEqual(runtime.getCounters(), { hits: 0, misses: 0, streak: 0, bestStreak: 0 });
|
||||
|
||||
sm.emit('song:stop', { time: 12 });
|
||||
@@ -103,7 +103,7 @@ test('DOM text updates after hit and miss events', () => {
|
||||
};
|
||||
|
||||
const runtime = hud.bindRuntime(sm, els);
|
||||
sm.emit('song:loading', { filename: 'song.psarc' });
|
||||
sm.emit('song:loading', { filename: 'song.archive' });
|
||||
|
||||
assert.equal(els.percent.textContent, '\u2014');
|
||||
assert.equal(els.hits.textContent, 'Waiting for notes');
|
||||
@@ -140,7 +140,7 @@ test('HUD stays hidden until the first note arrives, then reveals', () => {
|
||||
};
|
||||
|
||||
const runtime = hud.bindRuntime(sm, els);
|
||||
sm.emit('song:loading', { filename: 'song.psarc' });
|
||||
sm.emit('song:loading', { filename: 'song.archive' });
|
||||
// Primed (tallying) but not yet visible — a user without note detection
|
||||
// never gets note:hit/note:miss, so the HUD must not show on load alone.
|
||||
assert.equal(runtime.isActive(), true);
|
||||
@@ -152,7 +152,7 @@ test('HUD stays hidden until the first note arrives, then reveals', () => {
|
||||
// A new song re-hides until the next note.
|
||||
sm.emit('song:stop', { time: 1 });
|
||||
assert.ok(els.root.className.includes('hidden'));
|
||||
sm.emit('song:loading', { filename: 'song2.psarc' });
|
||||
sm.emit('song:loading', { filename: 'song2.archive' });
|
||||
assert.ok(els.root.className.includes('hidden'));
|
||||
});
|
||||
|
||||
|
||||
@@ -172,7 +172,7 @@ test('diagnostics contribution is redaction-safe', async () => {
|
||||
const contribution = window.__diagnosticsContributions.get('note-detection-capability');
|
||||
assert.equal(contribution.schema, 'slopsmith.note_detection_capability.v1');
|
||||
const serialized = JSON.stringify(contribution);
|
||||
assert.ok(!/Yamaha|deviceLabel|filename|\.sloppak|\.psarc/i.test(serialized), serialized);
|
||||
assert.ok(!/Yamaha|deviceLabel|filename|\.sloppak|\.archive/i.test(serialized), serialized);
|
||||
});
|
||||
|
||||
test('legacy setNoteStateProvider surface is wrapped and accounted', () => {
|
||||
@@ -227,7 +227,7 @@ test('_contextSummary whitelists arrangement kind — unknown values are dropped
|
||||
// Arbitrary string must not appear in context summary.
|
||||
const open2 = await api.dispatch({
|
||||
capability: 'note-detection', command: 'open-binding',
|
||||
source: 'caller', payload: { context: { arrangement: '/Users/victim/song.psarc' } },
|
||||
source: 'caller', payload: { context: { arrangement: '/Users/victim/song.archive' } },
|
||||
});
|
||||
assert.equal(open2.outcome, 'handled');
|
||||
const snap2 = window.slopsmith.noteDetection.snapshot();
|
||||
|
||||
@@ -68,8 +68,8 @@ test('legacy bridge hits are attributed to playback compatibility shims', () =>
|
||||
|
||||
test('legacy song events update playback state without exposing raw filenames', () => {
|
||||
const window = loadPlayback();
|
||||
window.slopsmith.emit('song:loading', { filename: '/Users/example/Secret Folder/Artist - Song_p.psarc', arrangement: 0 });
|
||||
window.slopsmith.emit('song:loaded', makeTarget({ filename: '/Users/example/Secret Folder/Artist - Song_p.psarc' }));
|
||||
window.slopsmith.emit('song:loading', { filename: '/Users/example/Secret Folder/Artist - Song_p.archive', arrangement: 0 });
|
||||
window.slopsmith.emit('song:loaded', makeTarget({ filename: '/Users/example/Secret Folder/Artist - Song_p.archive' }));
|
||||
window.slopsmith.emit('song:play', { time: 4, audioT: 4, chartT: 4 });
|
||||
window.slopsmith.emit('song:seek', { from: 4, to: 12, reason: 'seek-by' });
|
||||
|
||||
@@ -81,7 +81,7 @@ test('legacy song events update playback state without exposing raw filenames',
|
||||
assert.match(playback.state.target.settingsKey, /^settings-v1-[a-z0-9]{7}$/);
|
||||
assert.ok(playback.bridges.some(bridge => bridge.bridgeId === 'playback.song-events'));
|
||||
assert.doesNotMatch(encoded, /Secret Folder/);
|
||||
assert.doesNotMatch(encoded, /Artist - Song_p\.psarc/);
|
||||
assert.doesNotMatch(encoded, /Artist - Song_p\.archive/);
|
||||
});
|
||||
|
||||
test('route changes are captured as redaction-safe playback lifecycle events', () => {
|
||||
|
||||
@@ -9,7 +9,7 @@ test('exported diagnostics pseudonymize targets while local inspector may show d
|
||||
authorization: 'user-action',
|
||||
requesterId: 'core.player.controls',
|
||||
target: makeTarget({
|
||||
filename: '/Users/example/DLC/Private Artist - Private Song_p.psarc',
|
||||
filename: '/Users/example/DLC/Private Artist - Private Song_p.archive',
|
||||
title: 'Private Song',
|
||||
artist: 'Private Artist',
|
||||
arrangement: 'Lead',
|
||||
@@ -36,7 +36,7 @@ test('diagnostic history is bounded for current and stopped sessions', async ()
|
||||
window.slopsmith.playback.registerTransportAdapter(makeAdapter());
|
||||
|
||||
for (let index = 0; index < 7; index += 1) {
|
||||
await dispatch(window, 'start', { authorization: 'user-action', requesterId: 'core.player.controls', target: makeTarget({ filename: `song-${index}.psarc`, title: `Song ${index}` }) });
|
||||
await dispatch(window, 'start', { authorization: 'user-action', requesterId: 'core.player.controls', target: makeTarget({ filename: `song-${index}.archive`, title: `Song ${index}` }) });
|
||||
for (let seek = 0; seek < 12; seek += 1) {
|
||||
await dispatch(window, 'seek', { requesterId: 'core.player.controls', time: seek });
|
||||
}
|
||||
@@ -96,7 +96,7 @@ test('diagnostics redact requester ids and raw camel-case payload keys', async (
|
||||
accessToken: 'plain-secret-token',
|
||||
nativeHandleRef: 'native-secret-handle',
|
||||
mediaStream: 'raw-stream-id',
|
||||
reason: '/Users/example/private song.psarc token=secret',
|
||||
reason: '/Users/example/private song.archive token=secret',
|
||||
safeDetail: 'safe value',
|
||||
});
|
||||
window.slopsmith.playback.recordBridgeHit({
|
||||
|
||||
@@ -57,7 +57,7 @@ test('start requires a target and explicit user authorization for fresh audible
|
||||
test('settings key is stable across arrangements while target id remains arrangement scoped', async () => {
|
||||
const window = loadPlayback();
|
||||
window.slopsmith.playback.registerTransportAdapter(makeAdapter());
|
||||
const base = makeTarget({ filename: '/Users/example/DLC/Artist - Song_p.psarc', arrangement: 'Lead', arrangementIndex: 0 });
|
||||
const base = makeTarget({ filename: '/Users/example/DLC/Artist - Song_p.archive', arrangement: 'Lead', arrangementIndex: 0 });
|
||||
|
||||
await dispatch(window, 'start', { requesterId: 'core.player.controls', authorization: 'user-action', target: base });
|
||||
const leadTarget = diagnosticsSnapshot(window).state.target;
|
||||
|
||||
@@ -41,12 +41,12 @@ function dispatch(window, command, payload = {}, requester = 'test') {
|
||||
|
||||
function makeTarget(overrides = {}) {
|
||||
return {
|
||||
filename: overrides.filename || '/Users/example/DLC/Secret Artist - Song_p.psarc',
|
||||
filename: overrides.filename || '/Users/example/DLC/Secret Artist - Song_p.archive',
|
||||
title: overrides.title || 'Visible Title',
|
||||
artist: overrides.artist || 'Visible Artist',
|
||||
arrangement: overrides.arrangement || 'Lead',
|
||||
arrangementIndex: overrides.arrangementIndex ?? 0,
|
||||
format: overrides.format || 'psarc',
|
||||
format: overrides.format || 'archive',
|
||||
sourceKind: overrides.sourceKind || 'local',
|
||||
...overrides,
|
||||
};
|
||||
|
||||
@@ -172,7 +172,7 @@ test('new song load resets the HTML audio rate, not only the visible speed contr
|
||||
const sandbox = buildSandbox();
|
||||
loadPlaySong(sandbox);
|
||||
|
||||
await sandbox.__playSong('next-song.psarc');
|
||||
await sandbox.__playSong('next-song.archive');
|
||||
|
||||
assert.equal(sandbox.__elements.get('speed-slider').value, 100);
|
||||
assert.match(sandbox.__elements.get('speed-label').textContent, /^1\.0{1,2}x$/);
|
||||
@@ -184,7 +184,7 @@ test('new song load resets the desktop backing rate when the API is available',
|
||||
const sandbox = buildSandbox({ juceMode: true });
|
||||
loadPlaySong(sandbox);
|
||||
|
||||
await sandbox.__playSong('next-song.psarc');
|
||||
await sandbox.__playSong('next-song.archive');
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
assert.equal(sandbox.jucePlayer._speed, 1);
|
||||
|
||||
@@ -240,7 +240,7 @@ test('diagnostics contribution is redaction-safe (no song identity)', () => {
|
||||
const contribution = window.__diagnosticsContributions.get('visualization-capability');
|
||||
assert.equal(contribution.schema, 'slopsmith.visualization_capability.v1');
|
||||
const serialized = JSON.stringify(contribution);
|
||||
assert.ok(!/filename|title|artist|\.sloppak|\.psarc/i.test(serialized), serialized);
|
||||
assert.ok(!/filename|title|artist|\.sloppak|\.archive/i.test(serialized), serialized);
|
||||
assert.deepEqual(
|
||||
JSON.parse(JSON.stringify(contribution.lastAutoMatch)),
|
||||
{ resolved: 'piano', matched: true },
|
||||
|
||||
@@ -24,7 +24,7 @@ def client(tmp_path, monkeypatch):
|
||||
def _post_mapping(client, **overrides):
|
||||
payload = {
|
||||
"song_key": "settings-v1-song",
|
||||
"filename": "Artist - Song_p.psarc",
|
||||
"filename": "Artist - Song_p.archive",
|
||||
"tone_key": "Dist",
|
||||
"provider_id": "nam-tone",
|
||||
"provider_ref": "preset:42",
|
||||
@@ -80,7 +80,7 @@ def test_upsert_updates_provider_mapping_without_colliding_with_other_provider(c
|
||||
assert second["label"] == "Updated"
|
||||
assert rig["id"] != first["id"]
|
||||
|
||||
listed = client.get("/api/audio-effects/mappings", params={"filename": "Artist - Song_p.psarc"}).json()["mappings"]
|
||||
listed = client.get("/api/audio-effects/mappings", params={"filename": "Artist - Song_p.archive"}).json()["mappings"]
|
||||
assert len(listed) == 2
|
||||
|
||||
|
||||
|
||||
@@ -204,7 +204,7 @@ def test_client_audio_session_contribution_redacts_paths(tmp_path):
|
||||
kw["client_contributions"] = {
|
||||
"note_detect": {
|
||||
"schema": "slopsmith.audio_session.diagnostics.v1",
|
||||
"session": {"sessionId": str(home_path / "DLC" / "private-song.psarc")},
|
||||
"session": {"sessionId": str(home_path / "DLC" / "private-song.archive")},
|
||||
"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.psarc"
|
||||
secret_path = "/home/alice/Music/DLC/my_song.archive"
|
||||
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.psarc ok"]},
|
||||
{"level": "log", "msg": "ok", "args": ["loaded /home/alice/Music/DLC/my_song.archive 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.psarc" not in console["entries"][0]["args"][0]
|
||||
assert "my_song.archive" 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.psarc")
|
||||
out = r.redact_text("loaded from /dlc/songs/foo.archive")
|
||||
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.psarc")
|
||||
b = r.redact_text("Replaying Test-Artist_Test-Song.psarc again")
|
||||
a = r.redact_text("Loading Test-Artist_Test-Song.archive")
|
||||
b = r.redact_text("Replaying Test-Artist_Test-Song.archive 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.psarc")
|
||||
out_b = b.redact_text("Foo.psarc")
|
||||
out_a = a.redact_text("Foo.archive")
|
||||
out_b = b.redact_text("Foo.archive")
|
||||
assert out_a != out_b
|
||||
|
||||
|
||||
|
||||
@@ -126,7 +126,7 @@ def test_validate_drum_tab_rejects_non_int_version():
|
||||
|
||||
def test_validate_drum_tab_accepts_unknown_version():
|
||||
"""An unknown schema version is logged but the payload is still accepted —
|
||||
forward-compat per Principle IV (backwards-compatible CDLC library)."""
|
||||
forward-compat per Principle IV (backwards-compatible custom song library)."""
|
||||
ok, _ = drums.validate_drum_tab({"version": 99, "hits": []})
|
||||
assert ok
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ cover the new year sort and the rewritten tuning sort (now
|
||||
musical-distance-based instead of alphabetical).
|
||||
|
||||
Tests stub `MetadataDB` directly via `meta_db.put()`, bypassing the
|
||||
PSARC/sloppak scanner — same approach as test_settings_api.py.
|
||||
archive/sloppak scanner — same approach as test_settings_api.py.
|
||||
"""
|
||||
|
||||
import importlib
|
||||
@@ -37,7 +37,7 @@ def client(server_mod):
|
||||
|
||||
|
||||
def _put(server_mod, *, filename, title, artist, year="", arrangements=None,
|
||||
has_lyrics=False, format="psarc", stem_ids=None, tuning_name="E Standard",
|
||||
has_lyrics=False, format="archive", stem_ids=None, tuning_name="E Standard",
|
||||
tuning_sort_key=0, tuning_offsets="", mtime=1.0, size=1):
|
||||
server_mod.meta_db.put(filename, mtime, size, {
|
||||
"title": title, "artist": artist, "album": f"{artist} - LP",
|
||||
@@ -57,13 +57,13 @@ def _put(server_mod, *, filename, title, artist, year="", arrangements=None,
|
||||
@pytest.fixture()
|
||||
def seeded(server_mod):
|
||||
"""Populate 6 deterministic rows covering the matrix of axes."""
|
||||
_put(server_mod, filename="a.psarc", title="A song", artist="A Band",
|
||||
year="2010", has_lyrics=True, format="psarc",
|
||||
_put(server_mod, filename="a.archive", title="A song", artist="A Band",
|
||||
year="2010", has_lyrics=True, format="archive",
|
||||
arrangements=[{"index": 0, "name": "Lead", "notes": 100},
|
||||
{"index": 1, "name": "Rhythm", "notes": 80}],
|
||||
tuning_name="E Standard", tuning_sort_key=0)
|
||||
_put(server_mod, filename="b.psarc", title="B song", artist="B Band",
|
||||
year="2005", has_lyrics=False, format="psarc",
|
||||
_put(server_mod, filename="b.archive", title="B song", artist="B Band",
|
||||
year="2005", has_lyrics=False, format="archive",
|
||||
arrangements=[{"index": 0, "name": "Bass", "notes": 60}],
|
||||
tuning_name="Drop D", tuning_sort_key=-2)
|
||||
_put(server_mod, filename="c.sloppak", title="C song", artist="C Band",
|
||||
@@ -87,8 +87,8 @@ def seeded(server_mod):
|
||||
"Drop D", -2),
|
||||
)
|
||||
server_mod.meta_db.conn.commit()
|
||||
_put(server_mod, filename="f.psarc", title="F song", artist="F Band",
|
||||
year="2015", has_lyrics=True, format="psarc",
|
||||
_put(server_mod, filename="f.archive", title="F song", artist="F Band",
|
||||
year="2015", has_lyrics=True, format="archive",
|
||||
arrangements=[{"index": 0, "name": "Lead", "notes": 110},
|
||||
{"index": 1, "name": "Bass", "notes": 70}],
|
||||
tuning_name="Eb Standard", tuning_sort_key=-6)
|
||||
@@ -104,23 +104,23 @@ def test_arrangement_has_lead(client, seeded):
|
||||
data = _get(client, arrangements_has="Lead")
|
||||
files = {s["filename"] for s in data["songs"]}
|
||||
# Rows with Lead: a, d, e, f. Combo (c) does NOT match strict-name "Lead".
|
||||
assert files == {"a.psarc", "d.sloppak", "e.sloppak", "f.psarc"}
|
||||
assert files == {"a.archive", "d.sloppak", "e.sloppak", "f.archive"}
|
||||
|
||||
|
||||
def test_arrangement_has_or_within_axis(client, seeded):
|
||||
data = _get(client, arrangements_has="Lead,Bass")
|
||||
files = {s["filename"] for s in data["songs"]}
|
||||
# Lead OR Bass: a, b, d, e, f.
|
||||
assert files == {"a.psarc", "b.psarc", "d.sloppak", "e.sloppak", "f.psarc"}
|
||||
assert files == {"a.archive", "b.archive", "d.sloppak", "e.sloppak", "f.archive"}
|
||||
|
||||
|
||||
def test_arrangement_lacks_bass(client, seeded):
|
||||
data = _get(client, arrangements_lacks="Bass")
|
||||
files = {s["filename"] for s in data["songs"]}
|
||||
# b.psarc and f.psarc both have Bass, exclude them.
|
||||
assert "b.psarc" not in files
|
||||
assert "f.psarc" not in files
|
||||
assert "a.psarc" in files
|
||||
# b.archive and f.archive both have Bass, exclude them.
|
||||
assert "b.archive" not in files
|
||||
assert "f.archive" not in files
|
||||
assert "a.archive" in files
|
||||
|
||||
|
||||
# ── Arrangements axis — smart naming mode ───────────────────────────────────
|
||||
@@ -130,25 +130,25 @@ def seeded_smart(server_mod):
|
||||
"""Rows that exercise the smart-mode filter branches in _build_where."""
|
||||
# Row with explicit smart_name="Alt. Lead" — must match arrangements_has=Lead
|
||||
# in smart mode (LIKE 'Alt. Lead%').
|
||||
_put(server_mod, filename="alt.psarc", title="Alt", artist="Alt Band",
|
||||
_put(server_mod, filename="alt.archive", title="Alt", artist="Alt Band",
|
||||
arrangements=[{"index": 0, "name": "Lead", "notes": 100,
|
||||
"smart_name": "Alt. Lead"}])
|
||||
# Row with explicit smart_name="Bonus Rhythm".
|
||||
_put(server_mod, filename="bonus.psarc", title="Bon", artist="Bon Band",
|
||||
_put(server_mod, filename="bonus.archive", title="Bon", artist="Bon Band",
|
||||
arrangements=[{"index": 0, "name": "Rhythm", "notes": 50,
|
||||
"smart_name": "Bonus Rhythm"}])
|
||||
# Legacy-cached row WITHOUT smart_name key (json_type IS NULL):
|
||||
# name="Combo" → must match arrangements_has=Lead via name fallback.
|
||||
_put(server_mod, filename="combo-old.psarc", title="ComboOld", artist="X",
|
||||
_put(server_mod, filename="combo-old.archive", title="ComboOld", artist="X",
|
||||
arrangements=[{"index": 0, "name": "Combo", "notes": 80}])
|
||||
# Legacy-cached row WITHOUT smart_name where name="Bass 2" (load_song
|
||||
# synthesises this for real_bass_22 when manifest data is missing):
|
||||
# must match arrangements_has=Bass via the extras fallback.
|
||||
_put(server_mod, filename="bass2-old.psarc", title="Bass2Old", artist="Z",
|
||||
_put(server_mod, filename="bass2-old.archive", title="Bass2Old", artist="Z",
|
||||
arrangements=[{"index": 0, "name": "Bass 2", "notes": 70}])
|
||||
# Scanned ambiguous row with explicit smart_name=None (json_type='null'):
|
||||
# name="Combo" must NOT match Lead in smart mode (suppress name-fallback).
|
||||
_put(server_mod, filename="combo-ambig.psarc", title="ComboAmb", artist="Y",
|
||||
_put(server_mod, filename="combo-ambig.archive", title="ComboAmb", artist="Y",
|
||||
arrangements=[{"index": 0, "name": "Combo", "notes": 90,
|
||||
"smart_name": None}])
|
||||
|
||||
@@ -156,10 +156,10 @@ def seeded_smart(server_mod):
|
||||
def test_smart_mode_matches_alt_lead(client, seeded_smart):
|
||||
data = _get(client, arrangements_has="Lead", naming_mode="smart")
|
||||
files = {s["filename"] for s in data["songs"]}
|
||||
assert "alt.psarc" in files # smart_name="Alt. Lead"
|
||||
assert "combo-old.psarc" in files # name-fallback (key absent)
|
||||
assert "combo-ambig.psarc" not in files # explicit null suppresses fallback
|
||||
assert "bonus.psarc" not in files # Bonus Rhythm not Lead
|
||||
assert "alt.archive" in files # smart_name="Alt. Lead"
|
||||
assert "combo-old.archive" in files # name-fallback (key absent)
|
||||
assert "combo-ambig.archive" not in files # explicit null suppresses fallback
|
||||
assert "bonus.archive" not in files # Bonus Rhythm not Lead
|
||||
|
||||
|
||||
def test_smart_mode_matches_bass_2_via_fallback(client, seeded_smart):
|
||||
@@ -167,7 +167,7 @@ def test_smart_mode_matches_bass_2_via_fallback(client, seeded_smart):
|
||||
# in smart mode via the NULL-smart_name name-fallback extras.
|
||||
data = _get(client, arrangements_has="Bass", naming_mode="smart")
|
||||
files = {s["filename"] for s in data["songs"]}
|
||||
assert "bass2-old.psarc" in files
|
||||
assert "bass2-old.archive" in files
|
||||
|
||||
|
||||
def test_smart_mode_combo_normalized_to_lead(client, seeded_smart):
|
||||
@@ -184,10 +184,10 @@ def test_smart_mode_lacks_lead_excludes_alt_lead(client, seeded_smart):
|
||||
# smart_name is explicitly null (we don't know if they have Lead).
|
||||
data = _get(client, arrangements_lacks="Lead", naming_mode="smart")
|
||||
files = {s["filename"] for s in data["songs"]}
|
||||
assert "alt.psarc" not in files
|
||||
assert "combo-old.psarc" not in files
|
||||
assert "combo-ambig.psarc" not in files # ambiguous — don't claim it lacks Lead
|
||||
assert "bonus.psarc" in files # has Bonus Rhythm, no Lead variant
|
||||
assert "alt.archive" not in files
|
||||
assert "combo-old.archive" not in files
|
||||
assert "combo-ambig.archive" not in files # ambiguous — don't claim it lacks Lead
|
||||
assert "bonus.archive" in files # has Bonus Rhythm, no Lead variant
|
||||
|
||||
|
||||
# ── Lyrics axis ─────────────────────────────────────────────────────────────
|
||||
@@ -195,13 +195,13 @@ def test_smart_mode_lacks_lead_excludes_alt_lead(client, seeded_smart):
|
||||
def test_has_lyrics_require(client, seeded):
|
||||
data = _get(client, has_lyrics="1")
|
||||
files = {s["filename"] for s in data["songs"]}
|
||||
assert files == {"a.psarc", "c.sloppak", "f.psarc"}
|
||||
assert files == {"a.archive", "c.sloppak", "f.archive"}
|
||||
|
||||
|
||||
def test_has_lyrics_exclude(client, seeded):
|
||||
data = _get(client, has_lyrics="0")
|
||||
files = {s["filename"] for s in data["songs"]}
|
||||
assert files == {"b.psarc", "d.sloppak", "e.sloppak"}
|
||||
assert files == {"b.archive", "d.sloppak", "e.sloppak"}
|
||||
|
||||
|
||||
# ── Stems axis ──────────────────────────────────────────────────────────────
|
||||
@@ -218,14 +218,14 @@ def test_stems_has_or_within_axis(client, seeded):
|
||||
assert {s["filename"] for s in data["songs"]} == {"c.sloppak", "d.sloppak"}
|
||||
|
||||
|
||||
def test_stems_has_excludes_psarcs_and_legacy_null(client, seeded):
|
||||
"""PSARCs have empty stem_ids; legacy row has NULL. Both are
|
||||
def test_stems_has_excludes_archives_and_legacy_null(client, seeded):
|
||||
"""archives have empty stem_ids; legacy row has NULL. Both are
|
||||
excluded by stems_has — there's no proof the stem is present."""
|
||||
data = _get(client, stems_has="drums")
|
||||
files = {s["filename"] for s in data["songs"]}
|
||||
assert files == {"c.sloppak", "d.sloppak"}
|
||||
# PSARC rows missing.
|
||||
assert "a.psarc" not in files
|
||||
# archive rows missing.
|
||||
assert "a.archive" not in files
|
||||
# Legacy NULL row missing.
|
||||
assert "e.sloppak" not in files
|
||||
|
||||
@@ -235,9 +235,9 @@ def test_stems_lacks_other(client, seeded):
|
||||
files = {s["filename"] for s in data["songs"]}
|
||||
# c.sloppak has "other" — must be excluded.
|
||||
assert "c.sloppak" not in files
|
||||
# Everything else lacks it (PSARCs have empty stem_ids; legacy NULL
|
||||
# Everything else lacks it (archives have empty stem_ids; legacy NULL
|
||||
# also lacks it because json_each yields nothing).
|
||||
assert "a.psarc" in files
|
||||
assert "a.archive" in files
|
||||
|
||||
|
||||
# ── Tuning axis ─────────────────────────────────────────────────────────────
|
||||
@@ -245,12 +245,12 @@ def test_stems_lacks_other(client, seeded):
|
||||
def test_tunings_or_within_axis(client, seeded):
|
||||
data = _get(client, tunings="E Standard,Drop D")
|
||||
files = {s["filename"] for s in data["songs"]}
|
||||
assert files == {"a.psarc", "b.psarc", "c.sloppak", "e.sloppak"}
|
||||
assert files == {"a.archive", "b.archive", "c.sloppak", "e.sloppak"}
|
||||
|
||||
|
||||
def test_tunings_eb_standard_only(client, seeded):
|
||||
data = _get(client, tunings="Eb Standard")
|
||||
assert {s["filename"] for s in data["songs"]} == {"d.sloppak", "f.psarc"}
|
||||
assert {s["filename"] for s in data["songs"]} == {"d.sloppak", "f.archive"}
|
||||
|
||||
|
||||
# ── Combined cross-axis (AND) ───────────────────────────────────────────────
|
||||
@@ -261,7 +261,7 @@ def test_combined_axes(client, seeded):
|
||||
# a (Lead, lyrics, E Std) ✓
|
||||
# f (Lead, lyrics, Eb Std) ✗ (wrong tuning)
|
||||
# c is Combo not Lead
|
||||
assert {s["filename"] for s in data["songs"]} == {"a.psarc"}
|
||||
assert {s["filename"] for s in data["songs"]} == {"a.archive"}
|
||||
|
||||
|
||||
# ── Whitelist sanitization (defense-in-depth) ───────────────────────────────
|
||||
@@ -282,30 +282,30 @@ def test_year_sort_desc_newest_first(client, seeded):
|
||||
files = [s["filename"] for s in data["songs"]]
|
||||
# Years: c=2020, d=2018, f=2015, a=2010, b=2005, e=''.
|
||||
# Empty year goes to the bottom for both directions.
|
||||
assert files == ["c.sloppak", "d.sloppak", "f.psarc", "a.psarc", "b.psarc", "e.sloppak"]
|
||||
assert files == ["c.sloppak", "d.sloppak", "f.archive", "a.archive", "b.archive", "e.sloppak"]
|
||||
|
||||
|
||||
def test_year_sort_asc_oldest_first(client, seeded):
|
||||
data = _get(client, sort="year")
|
||||
files = [s["filename"] for s in data["songs"]]
|
||||
# Empty year still bottom — only the dated rows reverse.
|
||||
assert files == ["b.psarc", "a.psarc", "f.psarc", "d.sloppak", "c.sloppak", "e.sloppak"]
|
||||
assert files == ["b.archive", "a.archive", "f.archive", "d.sloppak", "c.sloppak", "e.sloppak"]
|
||||
|
||||
|
||||
def test_tuning_sort_down_tuned_before_up_tuned_at_same_distance(client, server_mod):
|
||||
"""Within an ABS(tuning_sort_key) tier, the down-tuned variant
|
||||
must come before the up-tuned one so the order matches Rocksmith's
|
||||
must come before the up-tuned one so the order matches the source game's
|
||||
grouping (Eb Standard before F Standard at distance 6, etc.).
|
||||
Earlier code used signed-key DESC for the tiebreaker, which put
|
||||
+6 before -6 — the opposite of intent. Regression for Copilot
|
||||
finding on PR #134."""
|
||||
_put(server_mod, filename="up.psarc", title="Up", artist="A",
|
||||
_put(server_mod, filename="up.archive", title="Up", artist="A",
|
||||
tuning_name="F Standard", tuning_sort_key=6,
|
||||
arrangements=[{"index": 0, "name": "Lead", "notes": 1}])
|
||||
_put(server_mod, filename="down.psarc", title="Down", artist="B",
|
||||
_put(server_mod, filename="down.archive", title="Down", artist="B",
|
||||
tuning_name="Eb Standard", tuning_sort_key=-6,
|
||||
arrangements=[{"index": 0, "name": "Lead", "notes": 1}])
|
||||
_put(server_mod, filename="std.psarc", title="Std", artist="C",
|
||||
_put(server_mod, filename="std.archive", title="Std", artist="C",
|
||||
tuning_name="E Standard", tuning_sort_key=0,
|
||||
arrangements=[{"index": 0, "name": "Lead", "notes": 1}])
|
||||
|
||||
@@ -328,15 +328,15 @@ def test_tuning_sort_pushes_empty_tuning_name_to_bottom(client, server_mod):
|
||||
Without a leading `(tuning_name='') ASC` term, ABS(0) collides with
|
||||
E Standard's 0 so unscanned rows would float to the top of the
|
||||
tuning sort. Regression for Copilot finding on PR #134."""
|
||||
_put(server_mod, filename="real.psarc", title="Real", artist="A",
|
||||
_put(server_mod, filename="real.archive", title="Real", artist="A",
|
||||
tuning_name="E Standard", tuning_sort_key=0,
|
||||
arrangements=[{"index": 0, "name": "Lead", "notes": 1}])
|
||||
_put(server_mod, filename="legacy.psarc", title="Legacy", artist="B",
|
||||
_put(server_mod, filename="legacy.archive", title="Legacy", artist="B",
|
||||
tuning_name="", tuning_sort_key=0,
|
||||
arrangements=[{"index": 0, "name": "Lead", "notes": 1}])
|
||||
data = _get(client, sort="tuning")
|
||||
files = [s["filename"] for s in data["songs"]]
|
||||
assert files == ["real.psarc", "legacy.psarc"]
|
||||
assert files == ["real.archive", "legacy.archive"]
|
||||
|
||||
|
||||
def test_tuning_sort_pushes_null_tuning_name_to_bottom(client, server_mod):
|
||||
@@ -348,7 +348,7 @@ def test_tuning_sort_pushes_null_tuning_name_to_bottom(client, server_mod):
|
||||
evaluates to NULL for those rows and NULLs sort *ahead of* 0 in
|
||||
SQLite's ASC ordering — the legacy row would float above E
|
||||
Standard. Regression for Copilot finding on PR #134."""
|
||||
_put(server_mod, filename="real.psarc", title="Real", artist="A",
|
||||
_put(server_mod, filename="real.archive", title="Real", artist="A",
|
||||
tuning_name="E Standard", tuning_sort_key=0,
|
||||
arrangements=[{"index": 0, "name": "Lead", "notes": 1}])
|
||||
# Direct INSERT with NULL tuning_name AND NULL tuning_sort_key —
|
||||
@@ -358,14 +358,14 @@ def test_tuning_sort_pushes_null_tuning_name_to_bottom(client, server_mod):
|
||||
"INSERT INTO songs (filename, mtime, size, title, artist, album, year, "
|
||||
"duration, tuning, arrangements, has_lyrics, format, stem_count, stem_ids, "
|
||||
"tuning_name, tuning_sort_key) "
|
||||
"VALUES (?, 1.0, 1, ?, ?, ?, '', 200.0, '', ?, 0, 'psarc', 0, '[]', NULL, NULL)",
|
||||
("legacy.psarc", "Legacy", "Z", "Z - LP", json.dumps([])),
|
||||
"VALUES (?, 1.0, 1, ?, ?, ?, '', 200.0, '', ?, 0, 'archive', 0, '[]', NULL, NULL)",
|
||||
("legacy.archive", "Legacy", "Z", "Z - LP", json.dumps([])),
|
||||
)
|
||||
server_mod.meta_db.conn.commit()
|
||||
|
||||
data = _get(client, sort="tuning")
|
||||
files = [s["filename"] for s in data["songs"]]
|
||||
assert files == ["real.psarc", "legacy.psarc"]
|
||||
assert files == ["real.archive", "legacy.archive"]
|
||||
|
||||
# /api/library/tuning-names should also exclude the NULL row from
|
||||
# the picker entirely (users can't usefully filter by an unknown
|
||||
@@ -380,9 +380,9 @@ def test_query_stats_artist_count_is_case_insensitive(client, server_mod):
|
||||
NOCASE — leading to mismatched totals when the same artist was
|
||||
indexed under different casings. Regression for Copilot finding
|
||||
on PR #134."""
|
||||
_put(server_mod, filename="x.psarc", title="X", artist="The Beatles",
|
||||
_put(server_mod, filename="x.archive", title="X", artist="The Beatles",
|
||||
arrangements=[{"index": 0, "name": "Lead", "notes": 1}])
|
||||
_put(server_mod, filename="y.psarc", title="Y", artist="the beatles",
|
||||
_put(server_mod, filename="y.archive", title="Y", artist="the beatles",
|
||||
arrangements=[{"index": 0, "name": "Lead", "notes": 1}])
|
||||
stats = client.get("/api/library/stats").json()
|
||||
assert stats["total_artists"] == 1
|
||||
@@ -391,7 +391,7 @@ def test_query_stats_artist_count_is_case_insensitive(client, server_mod):
|
||||
|
||||
|
||||
def test_query_stats_groups_non_ascii_artist_letters_under_hash(client, server_mod):
|
||||
_put(server_mod, filename="angstrom.psarc", title="Angstrom", artist="Ångström")
|
||||
_put(server_mod, filename="angstrom.archive", title="Angstrom", artist="Ångström")
|
||||
|
||||
stats = client.get("/api/library/stats").json()
|
||||
|
||||
@@ -445,7 +445,7 @@ def test_compound_sort_with_legacy_dir_desc_doesnt_error(client, seeded):
|
||||
# Order matches plain `sort=year` (legacy dir is ignored on
|
||||
# already-directional clauses). The point is no 500 from invalid SQL.
|
||||
files = [s["filename"] for s in r.json()["songs"]]
|
||||
assert files == ["b.psarc", "a.psarc", "f.psarc", "d.sloppak", "c.sloppak", "e.sloppak"]
|
||||
assert files == ["b.archive", "a.archive", "f.archive", "d.sloppak", "c.sloppak", "e.sloppak"]
|
||||
|
||||
|
||||
# ── Tuning sort by pitch distance (slopsmith#22) ────────────────────────────
|
||||
@@ -487,11 +487,11 @@ def test_tuning_offsets_served_in_library_list(client, server_mod):
|
||||
"""Raw offsets round-trip through the DB into the list payload so the v3
|
||||
client can render target notes (they are not derivable from the collapsed
|
||||
"Custom Tuning" name)."""
|
||||
_put(server_mod, filename="custom.psarc", title="C", artist="C Band",
|
||||
_put(server_mod, filename="custom.archive", title="C", artist="C Band",
|
||||
tuning_name="Custom Tuning", tuning_sort_key=-6,
|
||||
tuning_offsets="-2 0 0 0 -2 -2")
|
||||
songs = _get(client)["songs"]
|
||||
row = next(s for s in songs if s["filename"] == "custom.psarc")
|
||||
row = next(s for s in songs if s["filename"] == "custom.archive")
|
||||
assert row["tuning_offsets"] == "-2 0 0 0 -2 -2"
|
||||
assert row["tuning_name"] == "Custom Tuning"
|
||||
|
||||
@@ -500,10 +500,10 @@ def test_distinct_custom_tunings_stay_distinct(client, server_mod):
|
||||
"""Two different custom tunings both named "Custom Tuning" must remain
|
||||
separate filter entries (grouped on offsets), and each pill must select
|
||||
only its own songs."""
|
||||
_put(server_mod, filename="dadgad.psarc", title="One", artist="A",
|
||||
_put(server_mod, filename="dadgad.archive", title="One", artist="A",
|
||||
tuning_name="Custom Tuning", tuning_sort_key=-4,
|
||||
tuning_offsets="-2 0 0 0 -2 0")
|
||||
_put(server_mod, filename="openc.psarc", title="Two", artist="B",
|
||||
_put(server_mod, filename="openc.archive", title="Two", artist="B",
|
||||
tuning_name="Custom Tuning", tuning_sort_key=-8,
|
||||
tuning_offsets="-4 -2 -2 0 -2 -4")
|
||||
|
||||
@@ -516,17 +516,17 @@ def test_distinct_custom_tunings_stay_distinct(client, server_mod):
|
||||
|
||||
# Filtering by one tuning's key returns only that song.
|
||||
only = _get(client, tunings="-2 0 0 0 -2 0")
|
||||
assert [s["filename"] for s in only["songs"]] == ["dadgad.psarc"]
|
||||
assert [s["filename"] for s in only["songs"]] == ["dadgad.archive"]
|
||||
|
||||
|
||||
def test_legacy_rows_without_offsets_group_by_name(client, server_mod):
|
||||
"""Rows predating the offsets column (tuning_offsets='') still group/filter
|
||||
by tuning_name, preserving prior behaviour."""
|
||||
_put(server_mod, filename="estd.psarc", title="S", artist="A",
|
||||
_put(server_mod, filename="estd.archive", title="S", artist="A",
|
||||
tuning_name="E Standard", tuning_sort_key=0, tuning_offsets="")
|
||||
names = [t["name"] for t in client.get("/api/library/tuning-names").json()["tunings"]]
|
||||
assert "E Standard" in names
|
||||
assert [s["filename"] for s in _get(client, tunings="E Standard")["songs"]] == ["estd.psarc"]
|
||||
assert [s["filename"] for s in _get(client, tunings="E Standard")["songs"]] == ["estd.archive"]
|
||||
|
||||
|
||||
# ── Stats endpoint mirrors filtered totals ──────────────────────────────────
|
||||
@@ -553,14 +553,14 @@ def test_empty_values_are_no_ops(client, seeded):
|
||||
def test_artist_filter_returns_only_that_artist(client, seeded):
|
||||
data = _get(client, artist="A Band")
|
||||
assert data["total"] == 1
|
||||
assert {s["filename"] for s in data["songs"]} == {"a.psarc"}
|
||||
assert {s["filename"] for s in data["songs"]} == {"a.archive"}
|
||||
assert all(s["artist"] == "A Band" for s in data["songs"])
|
||||
|
||||
|
||||
def test_artist_and_album_filter(client, seeded):
|
||||
data = _get(client, artist="A Band", album="A Band - LP")
|
||||
assert data["total"] == 1
|
||||
assert data["songs"][0]["filename"] == "a.psarc"
|
||||
assert data["songs"][0]["filename"] == "a.archive"
|
||||
assert data["songs"][0]["album"] == "A Band - LP"
|
||||
|
||||
|
||||
@@ -575,4 +575,4 @@ def test_q_search_remains_fuzzy_with_artist_filter(client, seeded):
|
||||
def test_artist_filter_is_case_insensitive(client, seeded):
|
||||
data = _get(client, artist="a band")
|
||||
assert data["total"] == 1
|
||||
assert data["songs"][0]["filename"] == "a.psarc"
|
||||
assert data["songs"][0]["filename"] == "a.archive"
|
||||
|
||||
@@ -27,7 +27,7 @@ def client(server_mod):
|
||||
c.close()
|
||||
|
||||
|
||||
def _put(server_mod, filename="local.psarc", title="Local Song", artist="Local Artist"):
|
||||
def _put(server_mod, filename="local.archive", title="Local Song", artist="Local Artist"):
|
||||
server_mod.meta_db.put(filename, 1.0, 1, {
|
||||
"title": title,
|
||||
"artist": artist,
|
||||
@@ -37,7 +37,7 @@ def _put(server_mod, filename="local.psarc", title="Local Song", artist="Local A
|
||||
"tuning": "E Standard",
|
||||
"arrangements": [{"index": 0, "name": "Lead", "notes": 1}],
|
||||
"has_lyrics": True,
|
||||
"format": "psarc",
|
||||
"format": "archive",
|
||||
"stem_count": 0,
|
||||
"stem_ids": [],
|
||||
"tuning_name": "E Standard",
|
||||
@@ -66,7 +66,7 @@ class FakeLibraryProvider:
|
||||
"artist": "Remote Artist",
|
||||
"album": "Remote Album",
|
||||
"arrangements": [],
|
||||
"format": "psarc",
|
||||
"format": "archive",
|
||||
}], 1)
|
||||
|
||||
def query_artists(self, **kwargs):
|
||||
@@ -91,7 +91,7 @@ class FakeLibraryProvider:
|
||||
|
||||
def sync_song(self, song_id: str):
|
||||
self.sync_song_id = song_id
|
||||
return {"ok": True, "filename": "synced.psarc", "song_id": song_id}
|
||||
return {"ok": True, "filename": "synced.archive", "song_id": song_id}
|
||||
|
||||
|
||||
class ReadOnlyLibraryProvider:
|
||||
@@ -157,7 +157,7 @@ def test_local_provider_is_default_library_provider(server_mod, client):
|
||||
default_payload = client.get("/api/library").json()
|
||||
explicit_payload = client.get("/api/library", params={"provider": "local"}).json()
|
||||
assert default_payload == explicit_payload
|
||||
assert default_payload["songs"][0]["filename"] == "local.psarc"
|
||||
assert default_payload["songs"][0]["filename"] == "local.archive"
|
||||
|
||||
|
||||
def test_registered_provider_handles_library_endpoints(server_mod, client):
|
||||
@@ -178,7 +178,7 @@ def test_registered_provider_handles_library_endpoints(server_mod, client):
|
||||
"size": "12",
|
||||
"sort": "title-desc",
|
||||
"dir": "desc",
|
||||
"format": "psarc",
|
||||
"format": "archive",
|
||||
"favorites": "1",
|
||||
"arrangements_has": "Lead,Rhythm",
|
||||
"has_lyrics": "1",
|
||||
@@ -192,7 +192,7 @@ def test_registered_provider_handles_library_endpoints(server_mod, client):
|
||||
assert provider.page_kwargs["sort"] == "title-desc"
|
||||
assert provider.page_kwargs["direction"] == "desc"
|
||||
assert provider.page_kwargs["favorites_only"] is True
|
||||
assert provider.page_kwargs["format_filter"] == "psarc"
|
||||
assert provider.page_kwargs["format_filter"] == "archive"
|
||||
assert provider.page_kwargs["arrangements_has"] == ["Lead", "Rhythm"]
|
||||
assert provider.page_kwargs["has_lyrics"] == 1
|
||||
assert provider.page_kwargs["tunings"] == ["E Standard", "Drop D"]
|
||||
@@ -225,7 +225,7 @@ def test_registered_provider_handles_library_endpoints(server_mod, client):
|
||||
assert provider.art_song_id == "remote-song-id"
|
||||
|
||||
synced = client.post("/api/library/providers/remote:frodo/songs/remote-song-id/sync").json()
|
||||
assert synced == {"ok": True, "filename": "synced.psarc", "song_id": "remote-song-id"}
|
||||
assert synced == {"ok": True, "filename": "synced.archive", "song_id": "remote-song-id"}
|
||||
assert provider.sync_song_id == "remote-song-id"
|
||||
|
||||
|
||||
|
||||
@@ -58,5 +58,5 @@ def test_get_song_info_inside_dlc_is_404_not_403(dlc_client):
|
||||
"""A safe-but-missing path produces 404, not 403 — guards against
|
||||
over-rejecting legitimate filenames."""
|
||||
tc, _server, _dlc = dlc_client
|
||||
r = tc.get("/api/song/some-song.psarc")
|
||||
r = tc.get("/api/song/some-song.archive")
|
||||
assert r.status_code == 404, r.text
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Tests for lib/loosefolder.py — pure helpers for loose CDLC folders."""
|
||||
"""Tests for lib/loosefolder.py — pure helpers for loose custom song folders."""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
@@ -10,7 +10,7 @@ need the model:
|
||||
* `vocals_has_signal` — RMS gate over a synthesized WAV.
|
||||
* `whisperx_available` — graceful False when the package isn't installed.
|
||||
|
||||
The end-to-end positive case (PSARC without lyrics → sloppak with
|
||||
The end-to-end positive case (archive without lyrics → sloppak with
|
||||
auto-transcribed lyrics) is intentionally a manual verification step;
|
||||
see the plan's verification section.
|
||||
"""
|
||||
|
||||
@@ -677,7 +677,7 @@ def test_cent_offset_round_trips_through_wire_format(tmp_path):
|
||||
|
||||
|
||||
def test_cent_offset_non_finite_sanitized_to_zero(tmp_path):
|
||||
# Malformed CDLC can carry NaN/Infinity, which float() accepts but which
|
||||
# Malformed custom song can carry NaN/Infinity, which float() accepts but which
|
||||
# serialize to invalid JSON tokens over the song_info WebSocket. Parsing
|
||||
# must coerce them to a finite 0.0 so the payload stays valid JSON.
|
||||
for bad in ("NaN", "Infinity", "-Infinity", "inf", "nan"):
|
||||
|
||||
+21
-21
@@ -46,28 +46,28 @@ def test_create_requires_name(client):
|
||||
|
||||
|
||||
def test_add_remove_reorder_persists(client, server):
|
||||
for fn in ("a.psarc", "b.psarc", "c.psarc"):
|
||||
for fn in ("a.archive", "b.archive", "c.archive"):
|
||||
server.meta_db.put(fn, 0, 0, {})
|
||||
pid = client.post("/api/playlists", json={"name": "Set"}).json()["id"]
|
||||
client.post(f"/api/playlists/{pid}/songs", json={"filename": "a.psarc"})
|
||||
client.post(f"/api/playlists/{pid}/songs", json={"filename": "b.psarc"})
|
||||
client.post(f"/api/playlists/{pid}/songs", json={"filename": "c.psarc"})
|
||||
client.post(f"/api/playlists/{pid}/songs", json={"filename": "a.archive"})
|
||||
client.post(f"/api/playlists/{pid}/songs", json={"filename": "b.archive"})
|
||||
client.post(f"/api/playlists/{pid}/songs", json={"filename": "c.archive"})
|
||||
songs = client.get(f"/api/playlists/{pid}").json()["songs"]
|
||||
assert [s["filename"] for s in songs] == ["a.psarc", "b.psarc", "c.psarc"]
|
||||
assert [s["filename"] for s in songs] == ["a.archive", "b.archive", "c.archive"]
|
||||
# reorder
|
||||
client.post(f"/api/playlists/{pid}/reorder", json={"order": ["c.psarc", "a.psarc", "b.psarc"]})
|
||||
client.post(f"/api/playlists/{pid}/reorder", json={"order": ["c.archive", "a.archive", "b.archive"]})
|
||||
songs2 = client.get(f"/api/playlists/{pid}").json()["songs"]
|
||||
assert [s["filename"] for s in songs2] == ["c.psarc", "a.psarc", "b.psarc"]
|
||||
assert [s["filename"] for s in songs2] == ["c.archive", "a.archive", "b.archive"]
|
||||
# remove
|
||||
client.request("DELETE", f"/api/playlists/{pid}/songs/b.psarc")
|
||||
client.request("DELETE", f"/api/playlists/{pid}/songs/b.archive")
|
||||
songs3 = client.get(f"/api/playlists/{pid}").json()["songs"]
|
||||
assert [s["filename"] for s in songs3] == ["c.psarc", "a.psarc"]
|
||||
assert [s["filename"] for s in songs3] == ["c.archive", "a.archive"]
|
||||
|
||||
|
||||
def test_saved_for_later_toggle_and_protection(client):
|
||||
# Toggle creates the system playlist on first use.
|
||||
assert client.post("/api/saved/toggle", json={"filename": "x.psarc"}).json() == {"saved": True}
|
||||
assert client.post("/api/saved/toggle", json={"filename": "x.psarc"}).json() == {"saved": False}
|
||||
assert client.post("/api/saved/toggle", json={"filename": "x.archive"}).json() == {"saved": True}
|
||||
assert client.post("/api/saved/toggle", json={"filename": "x.archive"}).json() == {"saved": False}
|
||||
saved = next(p for p in client.get("/api/playlists").json() if p["system_key"] == "saved_for_later")
|
||||
# Cannot delete or rename the system playlist.
|
||||
assert client.delete(f"/api/playlists/{saved['id']}").status_code == 400
|
||||
@@ -76,12 +76,12 @@ def test_saved_for_later_toggle_and_protection(client):
|
||||
|
||||
def test_continue_session(client, server):
|
||||
assert client.get("/api/session/continue").json() is None
|
||||
for fn in ("one.psarc", "two.psarc"):
|
||||
for fn in ("one.archive", "two.archive"):
|
||||
server.meta_db.put(fn, 0, 0, {})
|
||||
client.post("/api/stats", json={"filename": "one.psarc", "score": 100, "accuracy": 0.5, "lastPlayPosition": 12.0})
|
||||
client.post("/api/stats", json={"filename": "two.psarc", "score": 200, "accuracy": 0.7, "lastPlayPosition": 30.0})
|
||||
client.post("/api/stats", json={"filename": "one.archive", "score": 100, "accuracy": 0.5, "lastPlayPosition": 12.0})
|
||||
client.post("/api/stats", json={"filename": "two.archive", "score": 200, "accuracy": 0.7, "lastPlayPosition": 30.0})
|
||||
cont = client.get("/api/session/continue").json()
|
||||
assert cont["filename"] == "two.psarc"
|
||||
assert cont["filename"] == "two.archive"
|
||||
assert cont["last_position"] == 30.0
|
||||
assert "art_url" in cont and "title" in cont
|
||||
|
||||
@@ -89,8 +89,8 @@ def test_continue_session(client, server):
|
||||
def test_add_song_to_missing_playlist_is_404(client, server):
|
||||
# add_playlist_song() must not insert an orphan row for a non-existent
|
||||
# playlist (the concurrent-delete TOCTOU); it returns None → handler 404s.
|
||||
assert server.meta_db.add_playlist_song(999999, "x.psarc") is None
|
||||
r = client.post("/api/playlists/999999/songs", json={"filename": "x.psarc"})
|
||||
assert server.meta_db.add_playlist_song(999999, "x.archive") is None
|
||||
r = client.post("/api/playlists/999999/songs", json={"filename": "x.archive"})
|
||||
assert r.status_code == 404
|
||||
|
||||
|
||||
@@ -98,11 +98,11 @@ def test_playlist_hides_dead_songs_when_library_populated(client, server):
|
||||
# A playlist song whose file no longer exists is hidden from contents + count
|
||||
# (mirrors the stats read-filter), but only while the library is populated.
|
||||
db = server.meta_db
|
||||
db.put("live.psarc", 0, 0, {"title": "Live"})
|
||||
db.put("live.archive", 0, 0, {"title": "Live"})
|
||||
pid = client.post("/api/playlists", json={"name": "P"}).json()["id"]
|
||||
client.post(f"/api/playlists/{pid}/songs", json={"filename": "live.psarc"})
|
||||
client.post(f"/api/playlists/{pid}/songs", json={"filename": "ghost.psarc"}) # never in songs
|
||||
client.post(f"/api/playlists/{pid}/songs", json={"filename": "live.archive"})
|
||||
client.post(f"/api/playlists/{pid}/songs", json={"filename": "ghost.archive"}) # never in songs
|
||||
pl = client.get(f"/api/playlists/{pid}").json()
|
||||
names = [s["filename"] for s in pl["songs"]]
|
||||
assert "live.psarc" in names and "ghost.psarc" not in names
|
||||
assert "live.archive" in names and "ghost.archive" not in names
|
||||
assert [p for p in client.get("/api/playlists").json() if p["id"] == pid][0]["count"] == 1
|
||||
|
||||
@@ -198,7 +198,7 @@ def test_load_content_missing_root_is_nonfatal(tmp_path):
|
||||
({"name": "Monkeys Medley"}, "guitar"),
|
||||
({}, "guitar"),
|
||||
(None, "guitar"),
|
||||
# name overrides generic guitar type (legacy PSARC keys arrangements)
|
||||
# name overrides generic guitar type (legacy archive keys arrangements)
|
||||
({"type": "lead", "name": "Keys"}, "keys"),
|
||||
({"type": "combo", "name": "Piano"}, "keys"),
|
||||
({"type": "lead", "name": "Bass"}, "bass"),
|
||||
@@ -270,7 +270,7 @@ def test_select_quests_small_pool_returns_all():
|
||||
|
||||
|
||||
def _song_event(**payload):
|
||||
base = {"filename": "song.psarc", "instrument": "guitar", "accuracy": 0.9, "score": 1000}
|
||||
base = {"filename": "song.archive", "instrument": "guitar", "accuracy": 0.9, "score": 1000}
|
||||
base.update(payload)
|
||||
return {"type": "song_completed", "payload": base}
|
||||
|
||||
@@ -290,10 +290,10 @@ def test_goal_matches_song_filters():
|
||||
{"type": "song_completed", "instrument": "guitar", "target": 1}, _song_event()
|
||||
)
|
||||
assert goal_matches_event(
|
||||
{"type": "song_completed", "filename": "song.psarc", "target": 1}, _song_event()
|
||||
{"type": "song_completed", "filename": "song.archive", "target": 1}, _song_event()
|
||||
)
|
||||
assert not goal_matches_event(
|
||||
{"type": "song_completed", "filename": "other.psarc", "target": 1}, _song_event()
|
||||
{"type": "song_completed", "filename": "other.archive", "target": 1}, _song_event()
|
||||
)
|
||||
assert not goal_matches_event(
|
||||
{"type": "song_completed", "min_score": 2000, "target": 1}, _song_event()
|
||||
@@ -472,18 +472,18 @@ def test_evaluate_event_counts_prior_completions_toward_levelup():
|
||||
def test_evaluate_event_distinct_dedupes_replays():
|
||||
content = _content()
|
||||
snapshot = _snapshot(paths={"guitar": 1}) # working level-2 set
|
||||
first = evaluate_event(_song_event(filename="a.psarc"), content, snapshot)
|
||||
first = evaluate_event(_song_event(filename="a.archive"), content, snapshot)
|
||||
ch = first["challenges"][0]
|
||||
assert ch["count"] == 1 and not ch["completed"]
|
||||
assert ch["detail"] == {"seen": ["a.psarc"]}
|
||||
assert ch["detail"] == {"seen": ["a.archive"]}
|
||||
|
||||
snapshot["challenges"] = {
|
||||
"guitar.l2.distinct": {"count": 1, "completed": False, "detail": ch["detail"]}
|
||||
}
|
||||
replay = evaluate_event(_song_event(filename="a.psarc"), content, snapshot)
|
||||
replay = evaluate_event(_song_event(filename="a.archive"), content, snapshot)
|
||||
assert replay["challenges"] == [] # same song again: no advance
|
||||
|
||||
other = evaluate_event(_song_event(filename="b.psarc"), content, snapshot)
|
||||
other = evaluate_event(_song_event(filename="b.archive"), content, snapshot)
|
||||
ch2 = other["challenges"][0]
|
||||
assert ch2["count"] == 2 and ch2["completed"]
|
||||
assert other["level_ups"] == [{"path_id": "guitar", "new_level": 2}]
|
||||
@@ -493,7 +493,7 @@ def test_evaluate_event_default_counts_replays():
|
||||
# guitar.l1.three has no distinct flag: the same song three times completes it.
|
||||
content = _content()
|
||||
snapshot = _snapshot(challenges={"guitar.l1.three": {"count": 2, "completed": False}})
|
||||
outcome = evaluate_event(_song_event(filename="same.psarc", accuracy=0.1), content, snapshot)
|
||||
outcome = evaluate_event(_song_event(filename="same.archive", accuracy=0.1), content, snapshot)
|
||||
by_id = {c["challenge_id"]: c for c in outcome["challenges"]}
|
||||
assert by_id["guitar.l1.three"]["count"] == 3
|
||||
assert by_id["guitar.l1.three"]["completed"] is True
|
||||
|
||||
@@ -82,7 +82,7 @@ def client(server):
|
||||
return TestClient(server.app)
|
||||
|
||||
|
||||
def _scored_play(client, filename="song.psarc", accuracy=0.9, score=900, arrangement=0):
|
||||
def _scored_play(client, filename="song.archive", accuracy=0.9, score=900, arrangement=0):
|
||||
return client.post("/api/stats", json={
|
||||
"filename": filename, "arrangement": arrangement,
|
||||
"score": score, "accuracy": accuracy,
|
||||
@@ -294,7 +294,7 @@ def test_stale_quest_completion_does_not_double_award(client, server, monkeypatc
|
||||
|
||||
summary = server.meta_db.record_progression_event(
|
||||
"song_completed",
|
||||
{"filename": "y.psarc", "instrument": "guitar", "accuracy": 0.5, "score": 100},
|
||||
{"filename": "y.archive", "instrument": "guitar", "accuracy": 0.5, "score": 100},
|
||||
server._get_progression_content(),
|
||||
)
|
||||
assert all(q["id"] != "d.one" for q in summary["quests_completed"])
|
||||
|
||||
+6
-6
@@ -133,7 +133,7 @@ def test_int_optional_falls_back_on_overflow():
|
||||
def test_parse_note_falls_back_to_default_on_malformed_numeric_attrs():
|
||||
"""Malformed numeric XML attributes degrade gracefully.
|
||||
|
||||
Third-party Rocksmith XML occasionally emits empty / non-numeric
|
||||
Third-party the source game XML occasionally emits empty / non-numeric
|
||||
values for fields like `rightHand`. `_int_optional` (used for
|
||||
optional metadata fields like `rightHand` and `pickDirection`)
|
||||
falls back to the caller's default instead of raising, so a
|
||||
@@ -442,7 +442,7 @@ def test_arrangement_from_wire_ignores_non_dict_tones():
|
||||
|
||||
|
||||
def test_arrangement_tones_wire_is_json_safe():
|
||||
# `definitions` is copied verbatim from the PSARC manifest — the wire
|
||||
# `definitions` is copied verbatim from the archive manifest — the wire
|
||||
# output must still be strict JSON (allow_nan=False, as the browser's
|
||||
# JSON.parse requires).
|
||||
arr = Arrangement(name="Lead", tones={
|
||||
@@ -754,14 +754,14 @@ def test_string_count_uses_tuning_length_for_sparse_7_string_guitar():
|
||||
|
||||
|
||||
def test_string_count_ignores_rs_padded_tuning_for_bass():
|
||||
# RS-XML bass: tuning is padded to length 6 with zeros at
|
||||
# arrangement XML bass: tuning is padded to length 6 with zeros at
|
||||
# indices 4-5. Even though len(tuning) == 6, we MUST NOT use
|
||||
# that as a 6-string signal (would mis-classify bass as
|
||||
# guitar). arrangement_string_count's `tuning_count = 0 if
|
||||
# tuning_len == 6 else tuning_len` rule takes care of this.
|
||||
arr = Arrangement(
|
||||
name="Bass",
|
||||
tuning=[0, -5, -10, -15, 0, 0], # bass with RS XML padding
|
||||
tuning=[0, -5, -10, -15, 0, 0], # bass with arrangement XML padding
|
||||
notes=[Note(time=float(i), string=i, fret=0) for i in range(4)],
|
||||
)
|
||||
assert arrangement_string_count(arr) == 4
|
||||
@@ -852,7 +852,7 @@ def test_smart_names_unknown_name_returns_none():
|
||||
|
||||
|
||||
def test_smart_names_name_fallback_when_path_flags_zero():
|
||||
# CDLC often leaves path flags at 0; fall back to arrangement name
|
||||
# custom song often leaves path flags at 0; fall back to arrangement name
|
||||
arrs = [_sarr(name="Lead"), _sarr(name="Rhythm"), _sarr(name="Bass")]
|
||||
assert compute_smart_names(arrs) == ["Lead", "Rhythm", "Bass"]
|
||||
|
||||
@@ -883,7 +883,7 @@ def test_smart_names_multiple_combos_get_alt_names():
|
||||
|
||||
|
||||
def test_smart_names_combo_and_bass_mixed():
|
||||
# Real-world CDLC: 3 Combo + 1 Bass, all path flags zero
|
||||
# Real-world custom song: 3 Combo + 1 Bass, all path flags zero
|
||||
arrs = [
|
||||
_sarr(name="Combo"),
|
||||
_sarr(name="Combo"),
|
||||
|
||||
@@ -28,19 +28,19 @@ def client(server):
|
||||
|
||||
|
||||
def test_scored_session_persists_and_increments_plays(client):
|
||||
r = client.post("/api/stats", json={"filename": "song.psarc", "arrangement": 0,
|
||||
r = client.post("/api/stats", json={"filename": "song.archive", "arrangement": 0,
|
||||
"score": 400, "accuracy": 0.6, "lastPlayPosition": 30.0})
|
||||
assert r.status_code == 200
|
||||
row = r.json()["stats"]
|
||||
assert row["plays"] == 1 and row["best_score"] == 400 and row["best_accuracy"] == pytest.approx(0.6)
|
||||
# A better replay raises best_* but plays keeps incrementing.
|
||||
r2 = client.post("/api/stats", json={"filename": "song.psarc", "score": 800, "accuracy": 0.9})
|
||||
r2 = client.post("/api/stats", json={"filename": "song.archive", "score": 800, "accuracy": 0.9})
|
||||
row2 = r2.json()["stats"]
|
||||
assert row2["plays"] == 2
|
||||
assert row2["best_score"] == 800 and row2["best_accuracy"] == pytest.approx(0.9)
|
||||
assert row2["last_score"] == 800
|
||||
# A worse replay: best preserved, last replaced, plays still up.
|
||||
r3 = client.post("/api/stats", json={"filename": "song.psarc", "score": 100, "accuracy": 0.3})
|
||||
r3 = client.post("/api/stats", json={"filename": "song.archive", "score": 100, "accuracy": 0.3})
|
||||
row3 = r3.json()["stats"]
|
||||
assert row3["plays"] == 3
|
||||
assert row3["best_score"] == 800 and row3["best_accuracy"] == pytest.approx(0.9)
|
||||
@@ -50,7 +50,7 @@ def test_scored_session_persists_and_increments_plays(client):
|
||||
def test_scored_session_awards_xp_and_streak(client, server):
|
||||
assert server.meta_db.get_xp() == 0
|
||||
from xp import xp_for_run
|
||||
r = client.post("/api/stats", json={"filename": "s.psarc", "score": 900, "accuracy": 0.95})
|
||||
r = client.post("/api/stats", json={"filename": "s.archive", "score": 900, "accuracy": 0.95})
|
||||
prog = r.json()["progress"]
|
||||
assert prog is not None
|
||||
assert prog["xp"] == xp_for_run(900)
|
||||
@@ -58,7 +58,7 @@ def test_scored_session_awards_xp_and_streak(client, server):
|
||||
|
||||
|
||||
def test_position_only_touch_does_not_increment_plays(client):
|
||||
r = client.post("/api/stats", json={"filename": "x.psarc", "lastPlayPosition": 42.0})
|
||||
r = client.post("/api/stats", json={"filename": "x.archive", "lastPlayPosition": 42.0})
|
||||
assert r.status_code == 200
|
||||
row = r.json()["stats"]
|
||||
assert row["plays"] == 0 and row["last_position"] == 42.0
|
||||
@@ -66,7 +66,7 @@ def test_position_only_touch_does_not_increment_plays(client):
|
||||
prog = r.json()["progress"]
|
||||
assert prog is not None and prog["current_streak"] == 1 and prog["xp"] == 0
|
||||
# A later scored session counts as the first play.
|
||||
r2 = client.post("/api/stats", json={"filename": "x.psarc", "score": 100, "accuracy": 0.5})
|
||||
r2 = client.post("/api/stats", json={"filename": "x.archive", "score": 100, "accuracy": 0.5})
|
||||
assert r2.json()["stats"]["plays"] == 1
|
||||
# The earlier resume position is preserved through the scored upsert? The
|
||||
# scored session omitted last_position, so it should keep 42.0.
|
||||
@@ -78,28 +78,28 @@ def test_stats_requires_filename(client):
|
||||
|
||||
|
||||
def test_stats_requires_score_or_position(client):
|
||||
assert client.post("/api/stats", json={"filename": "a.psarc"}).status_code == 400
|
||||
assert client.post("/api/stats", json={"filename": "a.archive"}).status_code == 400
|
||||
|
||||
|
||||
def test_get_song_stats_aggregates_arrangements(client):
|
||||
client.post("/api/stats", json={"filename": "multi.psarc", "arrangement": 0, "score": 300, "accuracy": 0.5})
|
||||
client.post("/api/stats", json={"filename": "multi.psarc", "arrangement": 1, "score": 700, "accuracy": 0.8})
|
||||
body = client.get("/api/stats/multi.psarc").json()
|
||||
client.post("/api/stats", json={"filename": "multi.archive", "arrangement": 0, "score": 300, "accuracy": 0.5})
|
||||
client.post("/api/stats", json={"filename": "multi.archive", "arrangement": 1, "score": 700, "accuracy": 0.8})
|
||||
body = client.get("/api/stats/multi.archive").json()
|
||||
assert body["plays"] == 2
|
||||
assert body["best_score"] == 700 and body["best_accuracy"] == pytest.approx(0.8)
|
||||
assert len(body["arrangements"]) == 2
|
||||
|
||||
|
||||
def test_recent_orders_by_last_played(client, server):
|
||||
for fn in ("first.psarc", "second.psarc"):
|
||||
for fn in ("first.archive", "second.archive"):
|
||||
server.meta_db.put(fn, 0, 0, {})
|
||||
client.post("/api/stats", json={"filename": "first.psarc", "score": 100, "accuracy": 0.5})
|
||||
client.post("/api/stats", json={"filename": "second.psarc", "score": 200, "accuracy": 0.6})
|
||||
client.post("/api/stats", json={"filename": "first.archive", "score": 100, "accuracy": 0.5})
|
||||
client.post("/api/stats", json={"filename": "second.archive", "score": 200, "accuracy": 0.6})
|
||||
recent = client.get("/api/stats/recent?limit=10").json()
|
||||
names = [r["filename"] for r in recent]
|
||||
# Most-recent first; both present.
|
||||
assert names[0] == "second.psarc"
|
||||
assert "first.psarc" in names
|
||||
assert names[0] == "second.archive"
|
||||
assert "first.archive" in names
|
||||
# Rows carry metadata fields for the dashboard.
|
||||
assert "art_url" in recent[0] and "title" in recent[0]
|
||||
|
||||
@@ -111,22 +111,22 @@ def test_stats_rejects_non_finite_score_accuracy(client):
|
||||
# never be persisted (a stored non-finite breaks later JSON serialization).
|
||||
# Numeric strings:
|
||||
for bad in ({"score": "inf", "accuracy": 0.5}, {"score": 100, "accuracy": "NaN"}):
|
||||
r = client.post("/api/stats", json={"filename": "nf.psarc", **bad})
|
||||
r = client.post("/api/stats", json={"filename": "nf.archive", **bad})
|
||||
assert r.status_code == 400, bad
|
||||
# Raw JSON Infinity/NaN literals — Python's json parser accepts these, so a
|
||||
# client really can send them (httpx's json= serializer cannot, hence the
|
||||
# raw body).
|
||||
import json as _json
|
||||
for raw in (_json.dumps({"filename": "nf.psarc", "score": float("inf"), "accuracy": 0.5}),
|
||||
_json.dumps({"filename": "nf.psarc", "score": 100, "accuracy": float("nan")})):
|
||||
for raw in (_json.dumps({"filename": "nf.archive", "score": float("inf"), "accuracy": 0.5}),
|
||||
_json.dumps({"filename": "nf.archive", "score": 100, "accuracy": float("nan")})):
|
||||
r = client.post("/api/stats", content=raw, headers={"Content-Type": "application/json"})
|
||||
assert r.status_code == 400, raw
|
||||
# The bad writes left no row behind.
|
||||
assert client.get("/api/stats/nf.psarc").json()["plays"] == 0
|
||||
assert client.get("/api/stats/nf.archive").json()["plays"] == 0
|
||||
|
||||
|
||||
def test_stats_rejects_non_finite_position(client):
|
||||
r = client.post("/api/stats", json={"filename": "p.psarc", "lastPlayPosition": "inf"})
|
||||
r = client.post("/api/stats", json={"filename": "p.archive", "lastPlayPosition": "inf"})
|
||||
assert r.status_code == 400
|
||||
|
||||
|
||||
@@ -139,29 +139,29 @@ def test_stats_bad_typed_fields_are_400_not_500(client):
|
||||
def test_position_touch_surfaces_in_recent_and_continue(client, server):
|
||||
# A non-scored resume touch must stamp last_played_at so it shows up in
|
||||
# both 'Jump back in' (recent) and Continue-Playing.
|
||||
server.meta_db.put("resume.psarc", 0, 0, {})
|
||||
r = client.post("/api/stats", json={"filename": "resume.psarc", "arrangement": 2,
|
||||
server.meta_db.put("resume.archive", 0, 0, {})
|
||||
r = client.post("/api/stats", json={"filename": "resume.archive", "arrangement": 2,
|
||||
"lastPlayPosition": 42.0})
|
||||
assert r.status_code == 200
|
||||
recent = client.get("/api/stats/recent?limit=10").json()
|
||||
assert "resume.psarc" in [x["filename"] for x in recent]
|
||||
assert "resume.archive" in [x["filename"] for x in recent]
|
||||
cont = client.get("/api/session/continue").json()
|
||||
assert cont and cont["filename"] == "resume.psarc"
|
||||
assert cont and cont["filename"] == "resume.archive"
|
||||
assert cont["arrangement"] == 2 and cont["last_position"] == pytest.approx(42.0)
|
||||
|
||||
|
||||
def test_stats_requires_score_and_accuracy_together(client):
|
||||
# Exactly one of score/accuracy (with or without a position) is ambiguous.
|
||||
assert client.post("/api/stats", json={"filename": "x.psarc", "score": 100}).status_code == 400
|
||||
assert client.post("/api/stats", json={"filename": "x.psarc", "accuracy": 0.5,
|
||||
assert client.post("/api/stats", json={"filename": "x.archive", "score": 100}).status_code == 400
|
||||
assert client.post("/api/stats", json={"filename": "x.archive", "accuracy": 0.5,
|
||||
"lastPlayPosition": 10.0}).status_code == 400
|
||||
|
||||
|
||||
def test_stats_rejects_out_of_range_score(client):
|
||||
# A huge but finite score passes isfinite() yet overflows SQLite INTEGER.
|
||||
r = client.post("/api/stats", json={"filename": "big.psarc", "score": 1e308, "accuracy": 0.5})
|
||||
r = client.post("/api/stats", json={"filename": "big.archive", "score": 1e308, "accuracy": 0.5})
|
||||
assert r.status_code == 400
|
||||
assert client.get("/api/stats/big.psarc").json()["plays"] == 0
|
||||
assert client.get("/api/stats/big.archive").json()["plays"] == 0
|
||||
|
||||
|
||||
def test_xp_award_rejects_bool_and_overflow(client):
|
||||
@@ -171,17 +171,17 @@ def test_xp_award_rejects_bool_and_overflow(client):
|
||||
|
||||
|
||||
def test_reorder_rejects_non_permutation(client, server):
|
||||
for fn in ("a.psarc", "b.psarc"):
|
||||
for fn in ("a.archive", "b.archive"):
|
||||
server.meta_db.put(fn, 0, 0, {})
|
||||
pid = client.post("/api/playlists", json={"name": "P"}).json()["id"]
|
||||
client.post(f"/api/playlists/{pid}/songs", json={"filename": "a.psarc"})
|
||||
client.post(f"/api/playlists/{pid}/songs", json={"filename": "b.psarc"})
|
||||
client.post(f"/api/playlists/{pid}/songs", json={"filename": "a.archive"})
|
||||
client.post(f"/api/playlists/{pid}/songs", json={"filename": "b.archive"})
|
||||
# Extra / missing / duplicate entries are all rejected.
|
||||
assert client.post(f"/api/playlists/{pid}/reorder", json={"order": ["a.psarc"]}).status_code == 400
|
||||
assert client.post(f"/api/playlists/{pid}/reorder", json={"order": ["a.archive"]}).status_code == 400
|
||||
assert client.post(f"/api/playlists/{pid}/reorder",
|
||||
json={"order": ["a.psarc", "a.psarc"]}).status_code == 400
|
||||
json={"order": ["a.archive", "a.archive"]}).status_code == 400
|
||||
assert client.post(f"/api/playlists/{pid}/reorder",
|
||||
json={"order": ["b.psarc", "a.psarc"]}).status_code == 200
|
||||
json={"order": ["b.archive", "a.archive"]}).status_code == 200
|
||||
|
||||
|
||||
def test_overflow_numeric_inputs_are_not_500(client):
|
||||
@@ -192,29 +192,29 @@ def test_overflow_numeric_inputs_are_not_500(client):
|
||||
headers={"Content-Type": "application/json"})
|
||||
assert r.status_code == 400
|
||||
# Huge/inf arrangement is rejected (400), never a 500.
|
||||
r2 = client.post("/api/stats", content=_json.dumps({"filename": "o.psarc", "arrangement": 1e309,
|
||||
r2 = client.post("/api/stats", content=_json.dumps({"filename": "o.archive", "arrangement": 1e309,
|
||||
"score": 10, "accuracy": 0.5}),
|
||||
headers={"Content-Type": "application/json"})
|
||||
assert r2.status_code == 400
|
||||
# An out-of-int64-range playlist id is a 404, not a 500.
|
||||
assert client.get("/api/playlists/%d" % (10 ** 30)).status_code == 404
|
||||
assert client.post("/api/playlists/%d/songs" % (10 ** 30),
|
||||
json={"filename": "x.psarc"}).status_code == 404
|
||||
json={"filename": "x.archive"}).status_code == 404
|
||||
|
||||
|
||||
def test_stats_rejects_non_integral_arrangement(client):
|
||||
# 1.9 / true must be rejected, not silently truncated to 1.
|
||||
assert client.post("/api/stats", json={"filename": "a.psarc", "arrangement": 1.9,
|
||||
assert client.post("/api/stats", json={"filename": "a.archive", "arrangement": 1.9,
|
||||
"score": 10, "accuracy": 0.5}).status_code == 400
|
||||
assert client.post("/api/stats", json={"filename": "a.psarc", "arrangement": True,
|
||||
assert client.post("/api/stats", json={"filename": "a.archive", "arrangement": True,
|
||||
"score": 10, "accuracy": 0.5}).status_code == 400
|
||||
|
||||
|
||||
def test_stats_rejects_out_of_range_accuracy(client):
|
||||
for acc in (5, -1, 1.5):
|
||||
assert client.post("/api/stats", json={"filename": "acc.psarc", "score": 10,
|
||||
assert client.post("/api/stats", json={"filename": "acc.archive", "score": 10,
|
||||
"accuracy": acc}).status_code == 400, acc
|
||||
assert client.get("/api/stats/acc.psarc").json()["plays"] == 0
|
||||
assert client.get("/api/stats/acc.archive").json()["plays"] == 0
|
||||
|
||||
|
||||
def test_xp_award_rejects_non_integral_amount(client):
|
||||
@@ -224,10 +224,10 @@ def test_xp_award_rejects_non_integral_amount(client):
|
||||
|
||||
def test_stats_rejects_boolean_numeric_fields(client):
|
||||
# JSON booleans must not be coerced via float() into a recorded play / position.
|
||||
assert client.post("/api/stats", json={"filename": "b.psarc", "score": True, "accuracy": 0.5}).status_code == 400
|
||||
assert client.post("/api/stats", json={"filename": "b.psarc", "score": 10, "accuracy": False}).status_code == 400
|
||||
assert client.post("/api/stats", json={"filename": "b.psarc", "lastPlayPosition": False}).status_code == 400
|
||||
assert client.get("/api/stats/b.psarc").json()["plays"] == 0
|
||||
assert client.post("/api/stats", json={"filename": "b.archive", "score": True, "accuracy": 0.5}).status_code == 400
|
||||
assert client.post("/api/stats", json={"filename": "b.archive", "score": 10, "accuracy": False}).status_code == 400
|
||||
assert client.post("/api/stats", json={"filename": "b.archive", "lastPlayPosition": False}).status_code == 400
|
||||
assert client.get("/api/stats/b.archive").json()["plays"] == 0
|
||||
|
||||
|
||||
def test_award_xp_service_tolerates_bad_amount(client, server):
|
||||
@@ -245,26 +245,26 @@ def test_best_map_includes_scored_zero_excludes_resume_only(client, server):
|
||||
# A scored 0% song (plays>0) must appear in /api/stats/best; a resume-only
|
||||
# touch (plays==0, default best 0) must not — both are real library songs,
|
||||
# so the exclusion is the plays>0 rule, not the existing-song filter.
|
||||
for fn in ("zero.psarc", "resume.psarc"):
|
||||
for fn in ("zero.archive", "resume.archive"):
|
||||
server.meta_db.put(fn, 0, 0, {})
|
||||
client.post("/api/stats", json={"filename": "zero.psarc", "score": 0, "accuracy": 0.0})
|
||||
client.post("/api/stats", json={"filename": "resume.psarc", "lastPlayPosition": 12.0})
|
||||
client.post("/api/stats", json={"filename": "zero.archive", "score": 0, "accuracy": 0.0})
|
||||
client.post("/api/stats", json={"filename": "resume.archive", "lastPlayPosition": 12.0})
|
||||
best = client.get("/api/stats/best").json()
|
||||
assert "zero.psarc" in best and best["zero.psarc"] == 0.0
|
||||
assert "resume.psarc" not in best
|
||||
assert "zero.archive" in best and best["zero.archive"] == 0.0
|
||||
assert "resume.archive" not in best
|
||||
|
||||
|
||||
def test_dead_song_stats_hidden_when_library_populated(client, server):
|
||||
# When the songs table is populated, stats for a song that ISN'T in it are
|
||||
# hidden from reads (race-free orphan handling) — but a present song shows.
|
||||
db = server.meta_db
|
||||
db.put("keep.psarc", 0, 0, {"title": "Keep"})
|
||||
client.post("/api/stats", json={"filename": "keep.psarc", "score": 200, "accuracy": 0.8})
|
||||
client.post("/api/stats", json={"filename": "ghost.psarc", "score": 100, "accuracy": 0.5}) # no songs row
|
||||
db.put("keep.archive", 0, 0, {"title": "Keep"})
|
||||
client.post("/api/stats", json={"filename": "keep.archive", "score": 200, "accuracy": 0.8})
|
||||
client.post("/api/stats", json={"filename": "ghost.archive", "score": 100, "accuracy": 0.5}) # no songs row
|
||||
best = client.get("/api/stats/best").json()
|
||||
assert "keep.psarc" in best and "ghost.psarc" not in best
|
||||
assert "keep.archive" in best and "ghost.archive" not in best
|
||||
recent = [r["filename"] for r in client.get("/api/stats/recent?limit=10").json()]
|
||||
assert "keep.psarc" in recent and "ghost.psarc" not in recent
|
||||
assert "keep.archive" in recent and "ghost.archive" not in recent
|
||||
|
||||
|
||||
def test_delete_missing_does_not_destroy_stats(client, server):
|
||||
@@ -273,23 +273,23 @@ def test_delete_missing_does_not_destroy_stats(client, server):
|
||||
# its full history. A second song keeps the library non-empty so the
|
||||
# existing-song read filter stays active.
|
||||
db = server.meta_db
|
||||
db.put("s.psarc", 0, 0, {"title": "S"})
|
||||
db.put("other.psarc", 0, 0, {"title": "Other"})
|
||||
client.post("/api/stats", json={"filename": "s.psarc", "score": 300, "accuracy": 0.9})
|
||||
db.delete_missing({"other.psarc"}) # s no longer "on disk" → songs row removed (other kept)
|
||||
assert "s.psarc" not in client.get("/api/stats/best").json() # hidden, library still populated
|
||||
db.put("s.psarc", 0, 0, {"title": "S"}) # song comes back under the same name
|
||||
db.put("s.archive", 0, 0, {"title": "S"})
|
||||
db.put("other.archive", 0, 0, {"title": "Other"})
|
||||
client.post("/api/stats", json={"filename": "s.archive", "score": 300, "accuracy": 0.9})
|
||||
db.delete_missing({"other.archive"}) # s no longer "on disk" → songs row removed (other kept)
|
||||
assert "s.archive" not in client.get("/api/stats/best").json() # hidden, library still populated
|
||||
db.put("s.archive", 0, 0, {"title": "S"}) # song comes back under the same name
|
||||
best = client.get("/api/stats/best").json()
|
||||
assert "s.psarc" in best and best["s.psarc"] == pytest.approx(0.9) # stats survived the prune
|
||||
assert "s.archive" in best and best["s.archive"] == pytest.approx(0.9) # stats survived the prune
|
||||
|
||||
|
||||
def test_stats_arrangement_bounded_to_song_arrangements(client, server):
|
||||
# For a known library song, an out-of-range arrangement index is rejected
|
||||
# (can't create a fake arrangement bucket); index 0 is fine.
|
||||
server.meta_db.put("multi.psarc", 0, 0, {"arrangements": [{"name": "Lead"}, {"name": "Bass"}]})
|
||||
assert client.post("/api/stats", json={"filename": "multi.psarc", "arrangement": 5,
|
||||
server.meta_db.put("multi.archive", 0, 0, {"arrangements": [{"name": "Lead"}, {"name": "Bass"}]})
|
||||
assert client.post("/api/stats", json={"filename": "multi.archive", "arrangement": 5,
|
||||
"score": 10, "accuracy": 0.5}).status_code == 400
|
||||
assert client.post("/api/stats", json={"filename": "multi.psarc", "arrangement": 1,
|
||||
assert client.post("/api/stats", json={"filename": "multi.archive", "arrangement": 1,
|
||||
"score": 10, "accuracy": 0.5}).status_code == 200
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user