Files
feedBack/tests/js/v3_songs_tuning.test.js
T
cc75cb876a fix(library): tuning filter answers for your instrument, not always guitar (#1003)
* fix(library): tuning filter answers for your instrument, not always guitar

The library indexed exactly one tuning per song, chosen guitar-first (lead >
rhythm > combo, bass only as a last resort), and nothing consulted the
player's instrument. A bassist filtering by tuning was shown the guitar
chart's tuning, so playlists built by tuning contained songs needing a
retune. Reported by a tester building bass practice sets; Covet "Shibuya" is
the clean case, with a custom guitar tuning over a standard bass chart.

Indexes each arrangement role's own tuning and makes the facet, filter, sort
and labels answer for one perspective. `guitar-lead` reads the original
unprefixed columns and adds no payload keys, so the default response is
unchanged. The same defect existed inside guitar -- lead and rhythm charts
can disagree -- so perspective is three-valued (guitar-lead, guitar-rhythm,
bass) driven by one PERSPECTIVES table rather than parallel column families.

Songs with no chart for the perspective fall back to the song-level tuning
rather than vanishing (18 of 59 packs in the test library have no bass
chart), but the fallback is marked inferred in the facet counts and on the
row instead of being silently coalesced. "Only real charts" reuses the
existing `arrangements_has` filter rather than adding one.

Bass-specific handling, from measured content:
- Bass tuning arrays are padded to six entries; charts never reference
  string index 4 or 5. Truncated to four before naming and grouping.
- Grouping uses a canonical open-pitch key, so [-2,0,0,0] and
  [-2,0,0,0,0,0] are one facet row instead of two.
- Offsets above +1 semitone are refused a name. Bassists tune down, near
  never up; one pack ships [5,5,5,5,4,4] (A-D-G-C, unplayable, and its own
  notes sit in the song's real key under standard tuning). Naming that
  would send a player to retune to a tuning that does not exist.

Rhythm deliberately does not truncate -- padding is a bass finding, and
cutting a seven-string array would invent a tuning the chart lacks.

Adds an opt-in `tuning_match=playable` mode alongside exact match: a chart is
offered when your lowest open pitch is at or below its lowest open pitch, so
a five-string bass covers four-string standard and drop-D with no retune.
Open strings only -- note range is not indexed and the scan stays
manifest-only -- so it fails conservative: unknown low pitch is excluded, and
the upper bound is unchecked and documented rather than guessed.

Existing installs would otherwise never populate: the tree-signature fast
path reports "unchanged" forever on a settled library. Rows with NULL marker
columns re-extract, and the fast path is disabled until that backfill
converges (writes use '' rather than NULL, so it self-clears).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SFDokqh2H6mEjk1Kgbi6JW
Signed-off-by: ChrisBeWithYou <christian.a.cowan@gmail.com>

* test(v3): accept the tuning-perspective indirection in the badge guard

The album-art badge now reads shownTuningName(), so the source-pattern guard
no longer matched the inline `tuning_name || tuning` form and CI went red.
Accept the helper, and pin the helper's own fallback in a companion test so
the guard still fails if a guitar player's tuning label is ever dropped.

Signed-off-by: ChrisBeWithYou <christian.a.cowan@gmail.com>

---------

Signed-off-by: ChrisBeWithYou <christian.a.cowan@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 00:04:30 -05:00

117 lines
5.1 KiB
JavaScript

'use strict';
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 SONGS_JS = path.join(__dirname, '..', '..', 'static', 'v3', 'songs.js');
// The tuning-display helpers were carved out of app.js into their own module (R3a).
const APP_JS = path.join(__dirname, '..', '..', 'static', 'js', 'tuning-display.js');
function extractBlock(src, startMarker) {
const start = src.indexOf(startMarker);
if (start === -1) throw new Error(`extractBlock: '${startMarker}' not found`);
const openBrace = src.indexOf('{', start);
let depth = 1;
let i = openBrace + 1;
while (i < src.length && depth > 0) {
const ch = src[i];
if (ch === '{') depth++;
else if (ch === '}') depth--;
i++;
}
if (depth !== 0) throw new Error(`extractBlock: unbalanced braces after '${startMarker}'`);
return src.slice(start, i);
}
function loadTuningHelpers() {
const src = fs.readFileSync(APP_JS, 'utf8');
// The module is nothing BUT the tuning helpers now, so there is no block to
// slice out — take it whole. `export` is stripped so the vm sandbox can still
// evaluate it as a plain script (the window.* contract lives in app.js).
const body = src.replace(/^export /gm, '');
const sandbox = { window: { feedBack: {} }, exports: {} };
vm.createContext(sandbox);
vm.runInContext(
body + '\n'
+ 'exports.displayTuningName = displayTuningName;\n'
+ 'exports.displayTuningTargets = displayTuningTargets;\n'
+ 'exports.parseRawTuningOffsets = parseRawTuningOffsets;',
sandbox
);
return sandbox.exports;
}
function renderSongCardBadge(song, helpers) {
const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => (
{ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
const tuningLabel = helpers.displayTuningName(song.tuning_name || song.tuning);
if (!tuningLabel) return '';
const rawOffsets = helpers.parseRawTuningOffsets(song.tuning_offsets)
|| helpers.parseRawTuningOffsets(song.tuning_name || song.tuning);
const targetNotes = (tuningLabel === 'Custom Tuning' && rawOffsets)
? helpers.displayTuningTargets(rawOffsets, { tuningName: tuningLabel })
: '';
if (targetNotes) {
return '<span class="absolute top-2 left-2 bg-fb-mid text-black text-[9px] font-bold px-1.5 py-0.5 rounded-sm leading-tight max-w-[5.5rem] text-center">'
+ esc('Custom Tuning') + '<br><span class="font-semibold tracking-wide">' + esc(targetNotes) + '</span></span>';
}
return '<span class="absolute top-2 left-2 bg-fb-mid text-black text-[10px] font-bold px-1.5 py-0.5 rounded-sm">' + esc(tuningLabel) + '</span>';
}
const helpers = loadTuningHelpers();
test('v3 songs.js uses display helpers for album-art tuning badge', () => {
const src = fs.readFileSync(SONGS_JS, 'utf8');
// The card renderer's row variable was renamed song → shown when grouped
// cards landed (the badge reads the representative chart); accept either.
// The raw read then moved behind shownTuningName() so the badge can answer
// for the active tuning perspective — accept that indirection too, and pin
// the fallback inside the helper below so this stays a real guard.
assert.match(
src,
/displayTuningName\((?:(?:song|shown)\.tuning_name \|\| (?:song|shown)\.tuning|shownTuning)\)/,
);
assert.match(src, /displayTuningTargets/);
assert.match(src, /parseRawTuningOffsets/);
});
test('the tuning-perspective helper still falls back to tuning_name || tuning', () => {
// shownTuningName() is what the badge now reads. With no perspective field
// set (guitar-lead, the default) it must resolve exactly what the badge
// used to read inline, or guitar players silently lose their tuning label.
const src = fs.readFileSync(SONGS_JS, 'utf8');
const body = src.match(/function shownTuningName\(song\)\s*\{[\s\S]*?\n {4}\}/);
assert.ok(body, 'shownTuningName() not found — the badge read moved again');
assert.match(body[0], /return song\.tuning_name \|\| song\.tuning;/);
});
test('raw offset tuning_name does not appear in rendered card HTML', () => {
const html = renderSongCardBadge({ tuning_name: '-2 0 0 0 -2' }, helpers);
assert.doesNotMatch(html, /-2 0 0 0 -2/);
assert.match(html, /Custom Tuning/);
});
test('custom tuning card shows low-to-high note sequence when offsets available', () => {
const html = renderSongCardBadge({
tuning_name: 'Custom Tuning',
tuning_offsets: [-2, 0, 0, 0, -2, -2],
}, helpers);
assert.match(html, /Custom Tuning/);
assert.match(html, /D A D G A D/);
assert.doesNotMatch(html, /6:|D2|5th/);
});
test('known tuning appears unchanged in card HTML', () => {
const html = renderSongCardBadge({ tuning_name: 'E Standard' }, helpers);
assert.match(html, /E Standard/);
assert.doesNotMatch(html, /<br>/);
});
test('missing tuning hides badge', () => {
const html = renderSongCardBadge({ tuning_name: '' }, helpers);
assert.equal(html, '');
});