mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-09-12 17:18:30 +00:00
Merge branch 'main' into chore/deprecate-sloppak-to-feedpak
# Conflicts: # lib/sloppak.py
This commit is contained in:
@@ -0,0 +1,90 @@
|
||||
// Behavioural tests for the per-note bend-curve (bnv, §6.2.1) render helpers:
|
||||
// `bnvNormalizedPoints` (static/highway.js, 2D glyph) and `bnvSampleAt`
|
||||
// (plugins/highway_3d/screen.js, 3D Y gesture). Both are pure, so we extract
|
||||
// the function source by brace-matching and eval it in isolation.
|
||||
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
function extractFn(src, name) {
|
||||
const start = src.indexOf('function ' + name);
|
||||
assert.ok(start >= 0, `function ${name} must exist`);
|
||||
const open = src.indexOf('{', start);
|
||||
let depth = 0;
|
||||
for (let i = open; i < src.length; i++) {
|
||||
if (src[i] === '{') depth++;
|
||||
else if (src[i] === '}' && --depth === 0) return src.slice(start, i + 1);
|
||||
}
|
||||
throw new Error(`unbalanced braces extracting ${name}`);
|
||||
}
|
||||
|
||||
function loadFn(file, name) {
|
||||
const src = fs.readFileSync(path.join(__dirname, '..', '..', file), 'utf8');
|
||||
return new Function('"use strict";' + extractFn(src, name) + `\nreturn ${name};`)();
|
||||
}
|
||||
|
||||
const bnvNormalizedPoints = loadFn('static/highway.js', 'bnvNormalizedPoints');
|
||||
const bnvSampleAt = loadFn('plugins/highway_3d/screen.js', 'bnvSampleAt');
|
||||
|
||||
// ── bnvNormalizedPoints (2D) ─────────────────────────────────────────────────
|
||||
|
||||
test('bnvNormalizedPoints normalizes t to 0..1 across the curve span (no sus)', () => {
|
||||
const pts = bnvNormalizedPoints([
|
||||
{ t: 0.5, v: 0 }, { t: 1.0, v: 2 }, { t: 1.5, v: 0 }]);
|
||||
assert.deepEqual(pts, [
|
||||
{ x: 0, v: 0 }, { x: 0.5, v: 2 }, { x: 1, v: 0 }]);
|
||||
});
|
||||
|
||||
test('bnvNormalizedPoints maps t over the note sus span when given', () => {
|
||||
// A bend that completes at t=0.4 of a 0.5s note draws to x=0.8, not x=1 —
|
||||
// i.e. it stops short of the glyph's right edge (correct timing shape).
|
||||
assert.deepEqual(
|
||||
bnvNormalizedPoints([{ t: 0, v: 0 }, { t: 0.25, v: 1 }, { t: 0.4, v: 0 }], 0.5),
|
||||
[{ x: 0, v: 0 }, { x: 0.5, v: 1 }, { x: 0.8, v: 0 }]);
|
||||
// Points beyond sus clamp to 1; sus<=0 falls back to curve-span mapping.
|
||||
assert.deepEqual(bnvNormalizedPoints([{ t: 0, v: 0 }, { t: 1, v: 2 }], 0.5),
|
||||
[{ x: 0, v: 0 }, { x: 1, v: 2 }]);
|
||||
assert.deepEqual(bnvNormalizedPoints([{ t: 0, v: 0 }, { t: 1, v: 2 }], 0),
|
||||
[{ x: 0, v: 0 }, { x: 1, v: 2 }]);
|
||||
});
|
||||
|
||||
test('bnvNormalizedPoints handles degenerate/empty input', () => {
|
||||
assert.deepEqual(bnvNormalizedPoints([]), []);
|
||||
assert.deepEqual(bnvNormalizedPoints(null), []);
|
||||
// All-same-t span collapses x to 0 (no divide-by-zero).
|
||||
assert.deepEqual(bnvNormalizedPoints([{ t: 1, v: 1 }, { t: 1, v: 2 }]),
|
||||
[{ x: 0, v: 1 }, { x: 0, v: 2 }]);
|
||||
});
|
||||
|
||||
// ── bnvSampleAt (3D) ─────────────────────────────────────────────────────────
|
||||
|
||||
test('bnvSampleAt linearly interpolates between points', () => {
|
||||
const bnv = [{ t: 0, v: 0 }, { t: 1, v: 2 }];
|
||||
assert.equal(bnvSampleAt(bnv, 0.5), 1); // midpoint
|
||||
assert.equal(bnvSampleAt(bnv, 0.25), 0.5);
|
||||
});
|
||||
|
||||
test('bnvSampleAt clamps to the endpoints', () => {
|
||||
const bnv = [{ t: 0.2, v: 1 }, { t: 0.8, v: 3 }];
|
||||
assert.equal(bnvSampleAt(bnv, 0), 1); // before first
|
||||
assert.equal(bnvSampleAt(bnv, 5), 3); // after last
|
||||
});
|
||||
|
||||
test('bnvSampleAt traces a round-trip curve up then back down', () => {
|
||||
const bnv = [{ t: 0, v: 0 }, { t: 0.5, v: 2 }, { t: 1, v: 0 }];
|
||||
assert.equal(bnvSampleAt(bnv, 0.25), 1); // rising
|
||||
assert.equal(bnvSampleAt(bnv, 0.5), 2); // peak
|
||||
assert.equal(bnvSampleAt(bnv, 0.75), 1); // falling
|
||||
});
|
||||
|
||||
test('bnvSampleAt returns 0 for an empty/invalid curve', () => {
|
||||
assert.equal(bnvSampleAt([], 0.5), 0);
|
||||
assert.equal(bnvSampleAt(null, 0.5), 0);
|
||||
});
|
||||
|
||||
test('bnvSampleAt tolerates a zero-width segment (duplicate t)', () => {
|
||||
const bnv = [{ t: 0, v: 0 }, { t: 0.5, v: 1 }, { t: 0.5, v: 2 }, { t: 1, v: 2 }];
|
||||
assert.equal(bnvSampleAt(bnv, 0.5), 1); // first matching segment wins
|
||||
});
|
||||
@@ -502,8 +502,12 @@ def test_attach_notation_to_sloppak(tmp_path):
|
||||
entries = {e["id"]: e for e in rewritten["arrangements"]}
|
||||
assert entries["keys"]["notation"] == "notation_keys.json"
|
||||
assert "notation" not in entries["lead"]
|
||||
# Key order preserved (sort_keys=False round-trip).
|
||||
assert list(rewritten.keys()) == ["title", "artist", "arrangements", "stems"]
|
||||
# Original key order preserved (sort_keys=False round-trip); the manifest
|
||||
# rewrite also stamps feedpak_version (spec §4), appended at the end.
|
||||
assert list(rewritten.keys()) == [
|
||||
"title", "artist", "arrangements", "stems", "feedpak_version"]
|
||||
from sloppak import FEEDPAK_VERSION
|
||||
assert rewritten["feedpak_version"] == FEEDPAK_VERSION
|
||||
|
||||
|
||||
def test_attach_notation_unknown_arrangement_raises(tmp_path):
|
||||
|
||||
+261
-3
@@ -20,9 +20,11 @@ import pytest
|
||||
from gp2rs import (
|
||||
GP_TICKS_PER_QUARTER,
|
||||
TempoEvent,
|
||||
_bend_intent_from_values,
|
||||
_build_playback_schedule,
|
||||
_compute_tuning,
|
||||
_extract_year,
|
||||
_gp_bend_shape,
|
||||
_gp_string_to_rs,
|
||||
_is_bass_track,
|
||||
_standard_tuning_for,
|
||||
@@ -795,12 +797,14 @@ def _ct_note(note_type, gp_string, fret):
|
||||
)
|
||||
|
||||
|
||||
def _ct_song(beats):
|
||||
"""One-measure mock song for convert_track, standard 6-string guitar at 120 BPM."""
|
||||
def _ct_song(beats, string_values=None):
|
||||
"""One-measure mock song for convert_track, standard 6-string guitar at 120 BPM.
|
||||
|
||||
`string_values` overrides the tuning/string count (e.g. a 7-string track)."""
|
||||
voice = SimpleNamespace(beats=beats)
|
||||
measure = SimpleNamespace(voices=[voice])
|
||||
strings = [SimpleNamespace(number=i + 1, value=v)
|
||||
for i, v in enumerate([64, 59, 55, 50, 45, 40])]
|
||||
for i, v in enumerate(string_values or [64, 59, 55, 50, 45, 40])]
|
||||
track = SimpleNamespace(
|
||||
strings=strings,
|
||||
channel=SimpleNamespace(instrument=24),
|
||||
@@ -862,6 +866,80 @@ def test_tied_note_without_predecessor_is_silently_dropped():
|
||||
assert len(notes) == 0
|
||||
|
||||
|
||||
# ── convert_track: bend shape (bn / bt / bnv, §6.2.1) ────────────────────────
|
||||
|
||||
def _ct_bend(points):
|
||||
"""A pyguitarpro-shaped BendEffect: points are (position 0..12, value)
|
||||
pairs where value is half-quarter-tone units (12 = 6 semitones)."""
|
||||
return SimpleNamespace(
|
||||
points=[SimpleNamespace(position=p, value=v) for p, v in points],
|
||||
)
|
||||
|
||||
|
||||
def test_bend_intent_classifier():
|
||||
assert _bend_intent_from_values([0.0, 1.0, 2.0]) == 0 # up
|
||||
assert _bend_intent_from_values([2.0, 1.0, 0.0]) == 3 # pre-bend+release
|
||||
assert _bend_intent_from_values([2.0, 2.0]) == 2 # pre-bend held
|
||||
assert _bend_intent_from_values([2.0, 1.0]) == 1 # release (let down)
|
||||
assert _bend_intent_from_values([0.0, 2.0, 0.0]) == 4 # round-trip
|
||||
assert _bend_intent_from_values([]) == 0
|
||||
|
||||
|
||||
def test_gp_bend_shape_units_and_time():
|
||||
"""value/2 = semitones; position/12 * duration = seconds-from-onset."""
|
||||
# 0.5 s note, up-bend 0 → value 4 (2 semitones) at the end.
|
||||
peak, intent, curve = _gp_bend_shape(_ct_bend([(0, 0), (12, 4)]), 0.5)
|
||||
assert peak == 2.0
|
||||
assert intent == 0
|
||||
assert curve == [{"t": 0.0, "v": 0.0}, {"t": 0.5, "v": 2.0}]
|
||||
# Zero-length note collapses every point to t=0 → no usable curve.
|
||||
_, _, curve0 = _gp_bend_shape(_ct_bend([(0, 0), (12, 4)]), 0.0)
|
||||
assert curve0 is None
|
||||
# A single point carries only the peak, no curve.
|
||||
_, _, curve1 = _gp_bend_shape(_ct_bend([(6, 4)]), 0.5)
|
||||
assert curve1 is None
|
||||
|
||||
|
||||
def test_bent_note_imports_with_curve_through_wire():
|
||||
"""A GP up-bend imports with bn (peak) + bt + bnv, and survives
|
||||
convert_track XML → _parse_note → note_to_wire."""
|
||||
from song import _parse_note, note_to_wire
|
||||
note = _ct_note(guitarpro.NoteType.normal, gp_string=1, fret=7)
|
||||
# quarter @ 120 BPM = 0.5 s; round-trip bend 0 → 2 → 0 semitones.
|
||||
note.effect.bend = _ct_bend([(0, 0), (6, 4), (12, 0)])
|
||||
beat = _ct_beat(tick=0, dur_value=4, notes=[note])
|
||||
|
||||
root = ET.fromstring(convert_track(_ct_song([beat]), track_index=0)) # noqa: S314
|
||||
xn = root.findall(".//notes/note")[0]
|
||||
assert xn.get("bend") == "2.0"
|
||||
assert xn.get("bendIntent") == "4" # round-trip
|
||||
import json
|
||||
assert json.loads(xn.get("bendValues")) == [
|
||||
{"t": 0.0, "v": 0.0}, {"t": 0.25, "v": 2.0}, {"t": 0.5, "v": 0.0}]
|
||||
|
||||
wire = note_to_wire(_parse_note(xn))
|
||||
assert wire["bn"] == 2.0
|
||||
assert wire["bt"] == 4
|
||||
assert wire["bnv"] == [
|
||||
{"t": 0.0, "v": 0.0}, {"t": 0.25, "v": 2.0}, {"t": 0.5, "v": 0.0}]
|
||||
|
||||
|
||||
def test_non_bent_note_has_no_curve():
|
||||
note = _ct_note(guitarpro.NoteType.normal, gp_string=1, fret=5) # bend=None
|
||||
beat = _ct_beat(tick=0, dur_value=4, notes=[note])
|
||||
root = ET.fromstring(convert_track(_ct_song([beat]), track_index=0)) # noqa: S314
|
||||
xn = root.findall(".//notes/note")[0]
|
||||
assert xn.get("bend") == "0"
|
||||
assert xn.get("bendIntent") is None
|
||||
assert xn.get("bendValues") is None
|
||||
|
||||
from song import _parse_note
|
||||
n = _parse_note(xn)
|
||||
assert n.bend == 0.0
|
||||
assert n.bend_intent == 0
|
||||
assert n.bend_values is None
|
||||
|
||||
|
||||
def _ct_multivoice_song(voices_beats):
|
||||
"""Multi-voice variant of _ct_song. `voices_beats` is a list of beat-lists,
|
||||
one per voice, all on the same single measure."""
|
||||
@@ -1100,3 +1178,183 @@ def test_tie_not_extended_across_repeat_boundary():
|
||||
assert sustain == pytest.approx(0.5, abs=0.01), (
|
||||
f"sustain should be ~0.5 s (one quarter note), got {sustain:.3f}"
|
||||
)
|
||||
|
||||
|
||||
# ── convert_track: GP5 chord-diagram fingering extraction (E3) ───────────────
|
||||
# pyguitarpro exposes the chord-diagram voicing on beat.effect.chord:
|
||||
# .strings is per-string frets indexed 0 = highest string, .fingerings is the
|
||||
# parallel Fingering enum list (open=-1, thumb=0, index=1, middle=2, ring=3,
|
||||
# pinky=4 — already the RS finger integers). A chord beat carrying this data
|
||||
# must import with per-string fingers; a chord beat without it stays all -1.
|
||||
|
||||
def _ct_chord(name, strings, fingerings):
|
||||
return SimpleNamespace(
|
||||
name=name, strings=list(strings),
|
||||
fingerings=list(fingerings), length=len(strings),
|
||||
)
|
||||
|
||||
|
||||
def test_chord_diagram_fingers_extracted():
|
||||
# Two-note voicing on high e (fret 3) + B (fret 2). chord.strings is
|
||||
# indexed 0 = highest string, so strings[0] = high e, strings[1] = B.
|
||||
note_e = _ct_note(guitarpro.NoteType.normal, gp_string=1, fret=3) # high e
|
||||
note_b = _ct_note(guitarpro.NoteType.normal, gp_string=2, fret=2) # B
|
||||
beat = _ct_beat(tick=0, dur_value=4, notes=[note_e, note_b])
|
||||
beat.effect.chord = _ct_chord(
|
||||
"Gtest",
|
||||
strings=[3, 2, -1, -1, -1, -1],
|
||||
fingerings=[
|
||||
guitarpro.Fingering.middle, # high e -> 2
|
||||
guitarpro.Fingering.index, # B -> 1
|
||||
],
|
||||
)
|
||||
|
||||
xml_str = convert_track(_ct_song([beat]), track_index=0)
|
||||
root = ET.fromstring(xml_str) # noqa: S314
|
||||
ct = root.find(".//chordTemplates/chordTemplate")
|
||||
assert ct is not None
|
||||
assert ct.get("chordName") == "Gtest"
|
||||
# _gp_string_to_rs(1, 6) = 5 (high e), _gp_string_to_rs(2, 6) = 4 (B).
|
||||
assert ct.get("fret5") == "3" and ct.get("finger5") == "2"
|
||||
assert ct.get("fret4") == "2" and ct.get("finger4") == "1"
|
||||
assert [ct.get(f"finger{i}") for i in range(0, 4)] == ["-1"] * 4
|
||||
|
||||
|
||||
def test_chord_without_diagram_has_blank_fingers():
|
||||
# A plain two-note chord (effect.chord is None) is unchanged: blank name,
|
||||
# all-(-1) fingers — no regression for diagram-less charts.
|
||||
note_e = _ct_note(guitarpro.NoteType.normal, gp_string=1, fret=3)
|
||||
note_b = _ct_note(guitarpro.NoteType.normal, gp_string=2, fret=2)
|
||||
beat = _ct_beat(tick=0, dur_value=4, notes=[note_e, note_b]) # chord=None
|
||||
|
||||
xml_str = convert_track(_ct_song([beat]), track_index=0)
|
||||
root = ET.fromstring(xml_str) # noqa: S314
|
||||
ct = root.find(".//chordTemplates/chordTemplate")
|
||||
assert ct is not None
|
||||
assert ct.get("chordName") == ""
|
||||
assert [ct.get(f"finger{i}") for i in range(6)] == ["-1"] * 6
|
||||
|
||||
|
||||
def test_chord_diagram_backfills_template_first_strummed_unannotated():
|
||||
# The annotated chord must enrich its voicing even when an earlier,
|
||||
# unannotated beat of the SAME fret pattern created the template first.
|
||||
plain = _ct_beat(
|
||||
tick=0, dur_value=4,
|
||||
notes=[_ct_note(guitarpro.NoteType.normal, gp_string=1, fret=3),
|
||||
_ct_note(guitarpro.NoteType.normal, gp_string=2, fret=2)],
|
||||
) # chord=None, creates the blank template
|
||||
annotated = _ct_beat(
|
||||
tick=GP_TICKS_PER_QUARTER, dur_value=4,
|
||||
notes=[_ct_note(guitarpro.NoteType.normal, gp_string=1, fret=3),
|
||||
_ct_note(guitarpro.NoteType.normal, gp_string=2, fret=2)],
|
||||
)
|
||||
annotated.effect.chord = _ct_chord(
|
||||
"Gtest", strings=[3, 2, -1, -1, -1, -1],
|
||||
fingerings=[guitarpro.Fingering.middle, guitarpro.Fingering.index],
|
||||
)
|
||||
|
||||
xml_str = convert_track(_ct_song([plain, annotated]), track_index=0)
|
||||
root = ET.fromstring(xml_str) # noqa: S314
|
||||
cts = root.findall(".//chordTemplates/chordTemplate")
|
||||
assert len(cts) == 1, "same voicing must dedup to one template"
|
||||
assert cts[0].get("chordName") == "Gtest"
|
||||
assert cts[0].get("finger5") == "2" and cts[0].get("finger4") == "1"
|
||||
|
||||
|
||||
def test_chord_diagram_mismatch_not_applied():
|
||||
# The attached diagram describes a DIFFERENT voicing (frets 5/5) than the
|
||||
# notes actually played (3/2). It must NOT enrich the played template —
|
||||
# otherwise a mislabeled chord would name/finger the wrong voicing (and the
|
||||
# back-fill would spread it). Name + fingers stay blank.
|
||||
note_e = _ct_note(guitarpro.NoteType.normal, gp_string=1, fret=3)
|
||||
note_b = _ct_note(guitarpro.NoteType.normal, gp_string=2, fret=2)
|
||||
beat = _ct_beat(tick=0, dur_value=4, notes=[note_e, note_b])
|
||||
beat.effect.chord = _ct_chord(
|
||||
"Wrong", strings=[5, 5, -1, -1, -1, -1], # != played 3/2
|
||||
fingerings=[guitarpro.Fingering.annular, guitarpro.Fingering.annular],
|
||||
)
|
||||
|
||||
xml_str = convert_track(_ct_song([beat]), track_index=0)
|
||||
root = ET.fromstring(xml_str) # noqa: S314
|
||||
ct = root.find(".//chordTemplates/chordTemplate")
|
||||
assert ct is not None
|
||||
assert ct.get("chordName") == ""
|
||||
assert [ct.get(f"finger{i}") for i in range(6)] == ["-1"] * 6
|
||||
|
||||
|
||||
def test_chord_diagram_name_then_fingers_decoupled():
|
||||
# First annotated beat carries a NAME but no fingers (all open); a later beat
|
||||
# of the same voicing carries the fingers. Both must land — a name-only first
|
||||
# annotation must not block the later fingers (name/fingers back-fill
|
||||
# independently).
|
||||
def _beat(tick, name, fingerings):
|
||||
b = _ct_beat(
|
||||
tick=tick, dur_value=4,
|
||||
notes=[_ct_note(guitarpro.NoteType.normal, gp_string=1, fret=3),
|
||||
_ct_note(guitarpro.NoteType.normal, gp_string=2, fret=2)],
|
||||
)
|
||||
b.effect.chord = _ct_chord(name, strings=[3, 2, -1, -1, -1, -1],
|
||||
fingerings=fingerings)
|
||||
return b
|
||||
|
||||
first = _beat(0, "Gtest",
|
||||
[guitarpro.Fingering.open, guitarpro.Fingering.open])
|
||||
second = _beat(GP_TICKS_PER_QUARTER, "",
|
||||
[guitarpro.Fingering.middle, guitarpro.Fingering.index])
|
||||
|
||||
xml_str = convert_track(_ct_song([first, second]), track_index=0)
|
||||
root = ET.fromstring(xml_str) # noqa: S314
|
||||
cts = root.findall(".//chordTemplates/chordTemplate")
|
||||
assert len(cts) == 1
|
||||
assert cts[0].get("chordName") == "Gtest" # from the first (name-only) beat
|
||||
# fingers from the second beat — not blocked by the first beat's name
|
||||
assert cts[0].get("finger5") == "2" and cts[0].get("finger4") == "1"
|
||||
|
||||
|
||||
def test_chord_diagram_barre_higher_position_matches():
|
||||
# A voicing high on the neck: diagram strings hold ABSOLUTE frets (firstFret
|
||||
# is display-only), so they match the played absolute frets and the template
|
||||
# enriches. Guards against an absolute-vs-relative matching regression.
|
||||
notes = [_ct_note(guitarpro.NoteType.normal, gp_string=1, fret=5),
|
||||
_ct_note(guitarpro.NoteType.normal, gp_string=2, fret=5),
|
||||
_ct_note(guitarpro.NoteType.normal, gp_string=3, fret=6)]
|
||||
beat = _ct_beat(tick=0, dur_value=4, notes=notes)
|
||||
ch = _ct_chord("A", strings=[5, 5, 6, -1, -1, -1],
|
||||
fingerings=[guitarpro.Fingering.index, guitarpro.Fingering.index,
|
||||
guitarpro.Fingering.middle])
|
||||
ch.firstFret = 5 # display base — must not affect matching
|
||||
beat.effect.chord = ch
|
||||
|
||||
xml_str = convert_track(_ct_song([beat]), track_index=0)
|
||||
root = ET.fromstring(xml_str) # noqa: S314
|
||||
ct = root.find(".//chordTemplates/chordTemplate")
|
||||
assert ct is not None
|
||||
assert ct.get("chordName") == "A"
|
||||
assert ct.get("fret5") == "5" and ct.get("finger5") == "1"
|
||||
assert ct.get("fret4") == "5" and ct.get("finger4") == "1"
|
||||
assert ct.get("fret3") == "6" and ct.get("finger3") == "2"
|
||||
|
||||
|
||||
def test_chord_diagram_extended_string_outside_played_width_not_applied():
|
||||
# 7-string track. Played voicing is on strings 2 & 3 only (width 6 — the
|
||||
# high e / rs6 is unused), but the diagram ALSO frets string 1 (the extended
|
||||
# rs6). The extra diagram note must make this a MISMATCH, not be silently
|
||||
# trimmed to a false match — so the played template stays un-enriched.
|
||||
seven = [64, 59, 55, 50, 45, 40, 35] # low-B 7-string
|
||||
note_b = _ct_note(guitarpro.NoteType.normal, gp_string=2, fret=3) # rs5
|
||||
note_g = _ct_note(guitarpro.NoteType.normal, gp_string=3, fret=2) # rs4
|
||||
beat = _ct_beat(tick=0, dur_value=4, notes=[note_b, note_g])
|
||||
# diagram index0 = gp_string1 (rs6) frets 5 (NOT played); index1/2 match.
|
||||
beat.effect.chord = _ct_chord(
|
||||
"Bogus", strings=[5, 3, 2, -1, -1, -1, -1],
|
||||
fingerings=[guitarpro.Fingering.index, guitarpro.Fingering.middle,
|
||||
guitarpro.Fingering.index],
|
||||
)
|
||||
|
||||
xml_str = convert_track(_ct_song([beat], string_values=seven), track_index=0)
|
||||
root = ET.fromstring(xml_str) # noqa: S314
|
||||
ct = root.find(".//chordTemplates/chordTemplate")
|
||||
assert ct is not None
|
||||
assert ct.get("chordName") == ""
|
||||
# played template is width 6 (rs6/high-e unused) -> finger0..finger5
|
||||
assert all(ct.get(f"finger{i}") == "-1" for i in range(6))
|
||||
|
||||
@@ -31,6 +31,7 @@ from gp2rs_gpx import (
|
||||
_collect_tone_events,
|
||||
_inject_tones,
|
||||
_resolve_pending_slides,
|
||||
_gpx_bend_shape,
|
||||
)
|
||||
from gp2rs import RsNote
|
||||
|
||||
@@ -55,6 +56,50 @@ def test_safe_filename_stem(name, expected):
|
||||
assert ".." not in out
|
||||
|
||||
|
||||
# ── _gpx_bend_shape (bn / bt / bnv, §6.2.1) ─────────────────────────────────
|
||||
|
||||
def _bend_props(**vals):
|
||||
"""Build a GPIF property map {name: <Property> element} for the given
|
||||
bend Float values, e.g. _bend_props(BendOriginValue=0, BendMiddleValue=100)."""
|
||||
tp = {}
|
||||
for name, num in vals.items():
|
||||
tp[name] = ET.fromstring(
|
||||
f'<Property name="{name}"><Float>{num}</Float></Property>')
|
||||
return tp
|
||||
|
||||
|
||||
def test_gpx_bend_shape_round_trip_curve():
|
||||
"""origin/middle/destination value+offset → 3-point bnv; value/divisor=semis."""
|
||||
tp = _bend_props(
|
||||
BendOriginValue=0, BendOriginOffset=0,
|
||||
BendMiddleValue=100, BendMiddleOffset1=50, # 100/50 = 2 semitones
|
||||
BendDestinationValue=0, BendDestinationOffset=100,
|
||||
)
|
||||
peak, intent, curve = _gpx_bend_shape(tp, divisor=50.0, sustain=1.0)
|
||||
assert peak == 2.0
|
||||
assert intent == 4 # round-trip (up then back down)
|
||||
assert curve == [
|
||||
{"t": 0.0, "v": 0.0}, {"t": 0.5, "v": 2.0}, {"t": 1.0, "v": 0.0}]
|
||||
|
||||
|
||||
def test_gpx_bend_shape_falls_back_to_even_spacing_without_offsets():
|
||||
tp = _bend_props(BendOriginValue=0, BendDestinationValue=100) # no offsets
|
||||
peak, intent, curve = _gpx_bend_shape(tp, divisor=50.0, sustain=1.0)
|
||||
assert peak == 2.0
|
||||
assert intent == 0 # plain up
|
||||
# origin defaults to 0%, destination to 100%.
|
||||
assert curve == [{"t": 0.0, "v": 0.0}, {"t": 1.0, "v": 2.0}]
|
||||
|
||||
|
||||
def test_gpx_bend_shape_no_props_and_zero_length():
|
||||
assert _gpx_bend_shape({}, divisor=50.0, sustain=1.0) == (0.0, 0, None)
|
||||
# Peak + intent still derived for a zero-length note, but no curve.
|
||||
peak, intent, curve = _gpx_bend_shape(
|
||||
_bend_props(BendOriginValue=0, BendDestinationValue=100),
|
||||
divisor=50.0, sustain=0.0)
|
||||
assert peak == 2.0 and intent == 0 and curve is None
|
||||
|
||||
|
||||
# ── _decompress_bcfz / _parse_bcfs input guards ─────────────────────────────
|
||||
|
||||
def test_decompress_bcfz_rejects_bad_magic():
|
||||
@@ -684,3 +729,88 @@ def test_note_vibrato_ignores_whammy_trembar_property():
|
||||
'</Properties></Note>')
|
||||
tp = {p.get('name'): p for p in n.findall('.//Property')}
|
||||
assert _note_has_vibrato(n, tp) is False
|
||||
|
||||
|
||||
# ── convert_file: GP8 chord-diagram name + fingering extraction (E3) ─────────
|
||||
# GP7/GP8 GPIF carries authored chord diagrams under a track's
|
||||
# Property[@name="DiagramCollection"]. Each Item gives the chord name and a
|
||||
# <Diagram> with per-string fret + finger. A played voicing matching that
|
||||
# fret pattern must import with the diagram's name + fingers; a chart without
|
||||
# a DiagramCollection must import with blank name + all-(-1) fingers.
|
||||
|
||||
def _gpif_chord_diagram(diagram_block: str) -> str:
|
||||
# A two-note chord (low E fret 3 + A fret 2) on a low->high tuned guitar.
|
||||
return f"""
|
||||
<GPIF>
|
||||
<Score><Title>T</Title><Artist>A</Artist></Score>
|
||||
<Tracks>
|
||||
<Track id="0"><Name>Lead Guitar</Name>
|
||||
<Property name="Tuning"><Pitches>40 45 50 55 59 64</Pitches></Property>
|
||||
{diagram_block}
|
||||
</Track>
|
||||
</Tracks>
|
||||
<MasterBars><MasterBar><Time>4/4</Time><Bars>0</Bars></MasterBar></MasterBars>
|
||||
<Bars><Bar id="0"><Voices>0</Voices></Bar></Bars>
|
||||
<Voices><Voice id="0"><Beats>0</Beats></Voice></Voices>
|
||||
<Beats><Beat id="0"><Rhythm ref="r0"/><Notes>0 1</Notes></Beat></Beats>
|
||||
<Notes>
|
||||
<Note id="0">
|
||||
<Property name="String"><String>0</String></Property>
|
||||
<Property name="Fret"><Fret>3</Fret></Property></Note>
|
||||
<Note id="1">
|
||||
<Property name="String"><String>1</String></Property>
|
||||
<Property name="Fret"><Fret>2</Fret></Property></Note>
|
||||
</Notes>
|
||||
<Rhythms><Rhythm id="r0"><NoteValue>Quarter</NoteValue></Rhythm></Rhythms>
|
||||
</GPIF>
|
||||
"""
|
||||
|
||||
|
||||
_DIAGRAM_BLOCK = """
|
||||
<Property name="DiagramCollection"><Items>
|
||||
<Item id="1" name="G5">
|
||||
<Diagram stringCount="6" fretCount="5" baseFret="0">
|
||||
<Fret string="0" fret="3"/>
|
||||
<Fret string="1" fret="2"/>
|
||||
<Fingering>
|
||||
<Position finger="Middle" fret="3" string="0"/>
|
||||
<Position finger="Index" fret="2" string="1"/>
|
||||
</Fingering>
|
||||
</Diagram>
|
||||
</Item>
|
||||
</Items></Property>
|
||||
"""
|
||||
|
||||
|
||||
def _convert_first_chord_template(monkeypatch, tmp_path, gpif):
|
||||
monkeypatch.setattr(gp2rs_gpx, "_load_gpif", lambda _p: ET.fromstring(gpif))
|
||||
out_files = convert_file(
|
||||
"dummy.gp", str(tmp_path),
|
||||
track_indices=[0], arrangement_names={0: "Lead"},
|
||||
)
|
||||
root = ET.parse(out_files[0]).getroot()
|
||||
cts = root.findall(".//chordTemplates/chordTemplate")
|
||||
assert len(cts) == 1
|
||||
return cts[0]
|
||||
|
||||
|
||||
def test_convert_file_gp8_chord_diagram_enriches_template(tmp_path, monkeypatch):
|
||||
ct = _convert_first_chord_template(
|
||||
monkeypatch, tmp_path, _gpif_chord_diagram(_DIAGRAM_BLOCK))
|
||||
# Diagram name + per-string fingering land on the matching voicing.
|
||||
assert ct.get("chordName") == "G5"
|
||||
# RS string 0 = low E (fret 3, Middle=2), string 1 = A (fret 2, Index=1).
|
||||
assert ct.get("fret0") == "3" and ct.get("finger0") == "2"
|
||||
assert ct.get("fret1") == "2" and ct.get("finger1") == "1"
|
||||
# Unplayed strings stay -1 for both fret and finger.
|
||||
assert [ct.get(f"finger{i}") for i in range(2, 6)] == ["-1"] * 4
|
||||
|
||||
|
||||
def test_convert_file_gp8_no_diagram_leaves_template_blank(tmp_path, monkeypatch):
|
||||
# Same chart, no DiagramCollection -> identical import to before E3.
|
||||
ct = _convert_first_chord_template(
|
||||
monkeypatch, tmp_path, _gpif_chord_diagram(""))
|
||||
assert ct.get("chordName") == ""
|
||||
assert [ct.get(f"finger{i}") for i in range(6)] == ["-1"] * 6
|
||||
# Fret pattern itself is unchanged (the join key still works).
|
||||
assert ct.get("fret0") == "3" and ct.get("fret1") == "2"
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
"""Album-art fast path + conditional-caching contract.
|
||||
|
||||
Covers the library cover-loading perf fix: `sloppak.read_cover_bytes` reads the
|
||||
cover WITHOUT unpacking the whole archive, and `GET /api/song/{f}/art` serves it
|
||||
with a content validator so re-scroll gets bodyless 304s — never a stale cover.
|
||||
|
||||
Pins, so a future refactor can't silently reintroduce:
|
||||
- the full-unpack-per-cover regression (covers served straight from the zip),
|
||||
- the non-canonical manifest cover name (`./cover.jpg`) 404,
|
||||
- zip-slip / degenerate cover names,
|
||||
- dir-form sloppaks emitting a stale 304 after an in-place cover edit.
|
||||
"""
|
||||
|
||||
import importlib
|
||||
import sys
|
||||
import zipfile
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
import sloppak as sloppak_mod
|
||||
|
||||
|
||||
# ── Unit: read_cover_bytes ────────────────────────────────────────────────────
|
||||
|
||||
def _zip_sloppak(path, cover_name="cover.jpg", manifest_cover="cover.jpg",
|
||||
cover_bytes=b"\xff\xd8\xff\xe0JPG", with_stem=True):
|
||||
with zipfile.ZipFile(path, "w") as zf:
|
||||
zf.writestr("manifest.yaml", yaml.safe_dump({"cover": manifest_cover}))
|
||||
zf.writestr(cover_name, cover_bytes)
|
||||
if with_stem:
|
||||
# A big-ish stem so a regression that unpacks the whole archive
|
||||
# would be doing real work, not just touching the cover.
|
||||
zf.writestr("stems/full.ogg", b"OggS" + b"\x00" * 4096)
|
||||
|
||||
|
||||
def _dir_sloppak(path, cover_bytes=b"\xff\xd8\xff\xe0JPG"):
|
||||
path.mkdir(parents=True)
|
||||
(path / "manifest.yaml").write_text(yaml.safe_dump({"cover": "cover.jpg"}))
|
||||
(path / "cover.jpg").write_bytes(cover_bytes)
|
||||
return path
|
||||
|
||||
|
||||
def test_read_cover_from_zip(tmp_path):
|
||||
z = tmp_path / "a.sloppak"
|
||||
_zip_sloppak(z, cover_bytes=b"\xff\xd8\xff\xe0HELLO")
|
||||
res = sloppak_mod.read_cover_bytes(z)
|
||||
assert res is not None
|
||||
data, mt = res
|
||||
assert data == b"\xff\xd8\xff\xe0HELLO"
|
||||
assert mt == "image/jpeg"
|
||||
|
||||
|
||||
def test_read_cover_from_dir(tmp_path):
|
||||
d = _dir_sloppak(tmp_path / "b.sloppak", cover_bytes=b"\xff\xd8\xff\xe0DIR")
|
||||
res = sloppak_mod.read_cover_bytes(d)
|
||||
assert res is not None and res[0] == b"\xff\xd8\xff\xe0DIR" and res[1] == "image/jpeg"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("manifest_cover", ["./cover.jpg", "art/../cover.jpg"])
|
||||
def test_noncanonical_manifest_cover_resolves(tmp_path, manifest_cover):
|
||||
"""A valid-but-non-canonical name must resolve to the real member, matching
|
||||
the old unpack-then-resolve-on-filesystem behavior."""
|
||||
z = tmp_path / "c.sloppak"
|
||||
_zip_sloppak(z, manifest_cover=manifest_cover, cover_bytes=b"\xff\xd8\xff\xe0X")
|
||||
res = sloppak_mod.read_cover_bytes(z)
|
||||
assert res is not None and res[0] == b"\xff\xd8\xff\xe0X"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("bad", ["../../escape.png", ".", "subdir/..", "/abs.png", ""])
|
||||
def test_unsafe_or_degenerate_cover_name_rejected(tmp_path, bad):
|
||||
z = tmp_path / "d.sloppak"
|
||||
# Put a real cover.jpg in the archive; the manifest points at the bad name.
|
||||
_zip_sloppak(z, manifest_cover=bad if bad else "cover.jpg")
|
||||
if bad == "":
|
||||
# Empty falls back to the default cover.jpg (intended contract).
|
||||
assert sloppak_mod.read_cover_bytes(z) is not None
|
||||
else:
|
||||
assert sloppak_mod.read_cover_bytes(z) is None
|
||||
|
||||
|
||||
def test_webp_media_type(tmp_path):
|
||||
z = tmp_path / "e.sloppak"
|
||||
_zip_sloppak(z, cover_name="cover.webp", manifest_cover="cover.webp",
|
||||
cover_bytes=b"RIFF....WEBP")
|
||||
res = sloppak_mod.read_cover_bytes(z)
|
||||
assert res is not None and res[1] == "image/webp"
|
||||
|
||||
|
||||
# ── Endpoint: conditional caching ─────────────────────────────────────────────
|
||||
|
||||
@pytest.fixture()
|
||||
def dlc_client(tmp_path, monkeypatch):
|
||||
"""TestClient with a temp DLC_DIR; sync startup, no scan, no plugins."""
|
||||
dlc = tmp_path / "dlc"
|
||||
dlc.mkdir()
|
||||
config = tmp_path / "cfg"
|
||||
config.mkdir()
|
||||
monkeypatch.setenv("DLC_DIR", str(dlc))
|
||||
monkeypatch.setenv("CONFIG_DIR", str(config))
|
||||
monkeypatch.setenv("SLOPSMITH_SYNC_STARTUP", "1")
|
||||
sys.modules.pop("server", None)
|
||||
server = importlib.import_module("server")
|
||||
server.sloppak_mod._source_cache.clear()
|
||||
monkeypatch.setattr(server, "load_plugins", lambda *a, **kw: None)
|
||||
monkeypatch.setattr(server, "startup_scan", lambda: None)
|
||||
static_tmp = tmp_path / "static"
|
||||
static_tmp.mkdir()
|
||||
monkeypatch.setattr(server, "STATIC_DIR", static_tmp)
|
||||
tc = TestClient(server.app, client=("127.0.0.1", 50000))
|
||||
try:
|
||||
yield tc, server, dlc
|
||||
finally:
|
||||
tc.close()
|
||||
meta_db = getattr(server, "meta_db", None)
|
||||
conn = getattr(meta_db, "conn", None)
|
||||
if conn is not None:
|
||||
conn.close()
|
||||
|
||||
|
||||
def test_zip_art_endpoint_conditional_304(dlc_client):
|
||||
tc, _server, dlc = dlc_client
|
||||
_zip_sloppak(dlc / "song.sloppak", cover_bytes=b"\xff\xd8\xff\xe0ZIP")
|
||||
r1 = tc.get("/api/song/song.sloppak/art")
|
||||
assert r1.status_code == 200
|
||||
assert r1.content == b"\xff\xd8\xff\xe0ZIP"
|
||||
assert r1.headers["cache-control"] == "no-cache"
|
||||
etag = r1.headers["etag"]
|
||||
assert etag
|
||||
r2 = tc.get("/api/song/song.sloppak/art", headers={"If-None-Match": etag})
|
||||
assert r2.status_code == 304
|
||||
assert r2.content == b""
|
||||
|
||||
|
||||
def test_dir_art_endpoint_no_stale_304_after_inplace_edit(dlc_client):
|
||||
"""Editing cover.jpg in place must invalidate the validator (the dir-form
|
||||
staleness bug: a dir-stat ETag would wrongly 304 here)."""
|
||||
tc, _server, dlc = dlc_client
|
||||
pak = _dir_sloppak(dlc / "dir.sloppak", cover_bytes=b"\xff\xd8\xff\xe0OLD")
|
||||
r1 = tc.get("/api/song/dir.sloppak/art")
|
||||
assert r1.status_code == 200 and r1.content == b"\xff\xd8\xff\xe0OLD"
|
||||
etag_old = r1.headers["etag"]
|
||||
# Replace the cover content in place (same path).
|
||||
(pak / "cover.jpg").write_bytes(b"\xff\xd8\xff\xe0NEW")
|
||||
r2 = tc.get("/api/song/dir.sloppak/art", headers={"If-None-Match": etag_old})
|
||||
assert r2.status_code == 200
|
||||
assert r2.content == b"\xff\xd8\xff\xe0NEW"
|
||||
@@ -0,0 +1,86 @@
|
||||
"""feedpak_version (spec §4): read on load + opportunistic stamp on a metadata
|
||||
write. Core has no create-from-scratch path (RS-free repo); the editor plugin's
|
||||
create-mode save stamping the version is a separate follow-up."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
|
||||
import sloppak as sloppak_mod
|
||||
from sloppak import FEEDPAK_VERSION
|
||||
from songmeta import write_sloppak_metadata
|
||||
|
||||
|
||||
def _write_dir_sloppak(root: Path, manifest_extras: dict) -> Path:
|
||||
pak = root / f"{root.name}.sloppak"
|
||||
pak.mkdir()
|
||||
arr_dir = pak / "arrangements"
|
||||
arr_dir.mkdir()
|
||||
(arr_dir / "lead.json").write_text(json.dumps({
|
||||
"name": "Lead", "tuning": [0, 0, 0, 0, 0, 0], "capo": 0,
|
||||
"notes": [], "chords": [], "anchors": [], "handshapes": [],
|
||||
"templates": [], "beats": [], "sections": [],
|
||||
}))
|
||||
manifest = {
|
||||
"title": "Test", "artist": "Tester", "album": "", "year": 2026,
|
||||
"duration": 10.0,
|
||||
"arrangements": [{"id": "lead", "name": "Lead", "file": "arrangements/lead.json"}],
|
||||
"stems": [{"id": "full", "file": "stems/full.ogg", "default": True}],
|
||||
}
|
||||
manifest.update(manifest_extras)
|
||||
(pak / "manifest.yaml").write_text(yaml.safe_dump(manifest, sort_keys=False))
|
||||
return pak
|
||||
|
||||
|
||||
def _load(pak: Path, tmp_path: Path):
|
||||
cache = tmp_path / "cache"
|
||||
cache.mkdir()
|
||||
return sloppak_mod.load_song(pak.name, pak.parent, cache)
|
||||
|
||||
|
||||
def _manifest(pak: Path) -> dict:
|
||||
return yaml.safe_load((pak / "manifest.yaml").read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
# ── read ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
def test_feedpak_version_read_from_manifest(tmp_path: Path):
|
||||
pak = _write_dir_sloppak(tmp_path, {"feedpak_version": "1.2.0"})
|
||||
assert _load(pak, tmp_path).feedpak_version == "1.2.0"
|
||||
|
||||
|
||||
def test_feedpak_version_none_when_absent(tmp_path: Path):
|
||||
pak = _write_dir_sloppak(tmp_path, {})
|
||||
assert _load(pak, tmp_path).feedpak_version is None
|
||||
|
||||
|
||||
def test_feedpak_version_none_when_not_a_string(tmp_path: Path):
|
||||
pak = _write_dir_sloppak(tmp_path, {"feedpak_version": 12})
|
||||
assert _load(pak, tmp_path).feedpak_version is None
|
||||
|
||||
|
||||
# ── opportunistic stamp on a metadata write ──────────────────────────────────
|
||||
|
||||
def test_metadata_write_stamps_version_when_absent(tmp_path: Path):
|
||||
pak = _write_dir_sloppak(tmp_path, {})
|
||||
assert "feedpak_version" not in _manifest(pak)
|
||||
assert write_sloppak_metadata(pak, {"title": "New"}) is True
|
||||
m = _manifest(pak)
|
||||
assert m["title"] == "New"
|
||||
assert m["feedpak_version"] == FEEDPAK_VERSION
|
||||
|
||||
|
||||
def test_metadata_write_preserves_existing_version(tmp_path: Path):
|
||||
pak = _write_dir_sloppak(tmp_path, {"feedpak_version": "9.9.9"})
|
||||
write_sloppak_metadata(pak, {"artist": "X"})
|
||||
assert _manifest(pak)["feedpak_version"] == "9.9.9" # not downgraded
|
||||
|
||||
|
||||
def test_metadata_no_change_does_not_add_version(tmp_path: Path):
|
||||
# A no-op metadata write must NOT stamp a version (no rewrite happens).
|
||||
pak = _write_dir_sloppak(tmp_path, {})
|
||||
assert write_sloppak_metadata(pak, {}) is False
|
||||
assert "feedpak_version" not in _manifest(pak)
|
||||
@@ -0,0 +1,127 @@
|
||||
"""End-to-end test for the sloppak loader recognising a `keys:` manifest key
|
||||
(keys.json — the song-level, instrument-independent key/scale track, spec §7.7)
|
||||
and surfacing the sanitized payload on the LoadedSloppak."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
|
||||
import sloppak as sloppak_mod
|
||||
|
||||
|
||||
def _write_dir_sloppak(root: Path, manifest_extras: dict, keys_payload) -> Path:
|
||||
"""Minimal directory-form sloppak; writes keys.json when a payload is given.
|
||||
|
||||
Unique filename per test (tmp_path leaf) so the module-level
|
||||
resolve_source_dir cache isn't poisoned across tests."""
|
||||
pak = root / f"{root.name}.sloppak"
|
||||
pak.mkdir()
|
||||
arr_dir = pak / "arrangements"
|
||||
arr_dir.mkdir()
|
||||
arr = {
|
||||
"name": "Lead", "tuning": [0, 0, 0, 0, 0, 0], "capo": 0,
|
||||
"notes": [], "chords": [], "anchors": [], "handshapes": [],
|
||||
"templates": [], "beats": [], "sections": [],
|
||||
}
|
||||
(arr_dir / "lead.json").write_text(json.dumps(arr))
|
||||
|
||||
manifest = {
|
||||
"title": "Test", "artist": "Tester", "album": "", "year": 2026,
|
||||
"duration": 10.0,
|
||||
"arrangements": [{"id": "lead", "name": "Lead", "file": "arrangements/lead.json"}],
|
||||
"stems": [{"id": "full", "file": "stems/full.ogg", "default": True}],
|
||||
}
|
||||
manifest.update(manifest_extras)
|
||||
(pak / "manifest.yaml").write_text(yaml.safe_dump(manifest, sort_keys=False))
|
||||
|
||||
if keys_payload is not None:
|
||||
(pak / "keys.json").write_text(json.dumps(keys_payload))
|
||||
return pak
|
||||
|
||||
|
||||
def _load(pak_path: Path, tmp_path: Path):
|
||||
dlc_root = pak_path.parent
|
||||
cache = tmp_path / "cache"
|
||||
cache.mkdir()
|
||||
return sloppak_mod.load_song(pak_path.name, dlc_root, cache)
|
||||
|
||||
|
||||
# ── Happy path ───────────────────────────────────────────────────────────────
|
||||
|
||||
def test_load_song_attaches_keys_when_manifest_opts_in(tmp_path: Path):
|
||||
payload = {
|
||||
"version": 1,
|
||||
"events": [
|
||||
{"t": 0.0, "key": "Em", "scale": "natural_minor"},
|
||||
{"t": 2.0, "key": "G", "scale": "major"},
|
||||
],
|
||||
}
|
||||
pak = _write_dir_sloppak(tmp_path, {"keys": "keys.json"}, payload)
|
||||
loaded = _load(pak, tmp_path)
|
||||
assert loaded.keys is not None
|
||||
assert loaded.keys["version"] == 1
|
||||
evs = loaded.keys["events"]
|
||||
assert len(evs) == 2
|
||||
assert evs[0] == {"t": 0.0, "key": "Em", "scale": "natural_minor"}
|
||||
assert evs[1] == {"t": 2.0, "key": "G", "scale": "major"}
|
||||
|
||||
|
||||
# ── Absent / permissive ──────────────────────────────────────────────────────
|
||||
|
||||
def test_load_song_keys_absent_when_manifest_silent(tmp_path: Path):
|
||||
pak = _write_dir_sloppak(tmp_path, {}, None)
|
||||
assert _load(pak, tmp_path).keys is None
|
||||
|
||||
|
||||
def test_load_song_keys_absent_when_file_missing(tmp_path: Path):
|
||||
pak = _write_dir_sloppak(tmp_path, {"keys": "nope.json"}, None)
|
||||
assert _load(pak, tmp_path).keys is None
|
||||
|
||||
|
||||
def test_load_song_keys_absent_when_invalid_json(tmp_path: Path):
|
||||
pak = _write_dir_sloppak(tmp_path, {"keys": "keys.json"}, None)
|
||||
(pak / "keys.json").write_text("not json {{{")
|
||||
assert _load(pak, tmp_path).keys is None
|
||||
|
||||
|
||||
def test_load_song_keys_ignored_when_events_not_a_list(tmp_path: Path):
|
||||
pak = _write_dir_sloppak(tmp_path, {"keys": "keys.json"},
|
||||
{"version": 1, "events": "nope"})
|
||||
assert _load(pak, tmp_path).keys is None
|
||||
|
||||
|
||||
# ── Sanitization ─────────────────────────────────────────────────────────────
|
||||
|
||||
def test_load_song_keys_sanitizes_and_sorts(tmp_path: Path):
|
||||
payload = {
|
||||
"version": 1,
|
||||
"events": [
|
||||
{"t": 2.0, "key": "G"}, # no scale -> omitted
|
||||
{"t": 0.0, "key": "Em", "scale": "major"}, # out of order
|
||||
{"t": 1.0}, # no key -> dropped
|
||||
{"foo": "bar"}, # not an event -> dropped
|
||||
{"t": 3.0, "key": ""}, # empty key -> dropped
|
||||
{"t": "bad", "key": "X"}, # non-numeric t -> dropped
|
||||
"garbage", # non-dict -> dropped
|
||||
],
|
||||
}
|
||||
pak = _write_dir_sloppak(tmp_path, {"keys": "keys.json"}, payload)
|
||||
evs = _load(pak, tmp_path).keys["events"]
|
||||
assert evs == [
|
||||
{"t": 0.0, "key": "Em", "scale": "major"},
|
||||
{"t": 2.0, "key": "G"}, # scale absent, not null
|
||||
]
|
||||
|
||||
|
||||
def test_load_song_keys_nonint_version_does_not_abort_load(tmp_path: Path):
|
||||
# json.loads accepts NaN; a float/NaN version must not raise int(NaN) and
|
||||
# abort the load of an OPTIONAL side-file — it falls back to version 1.
|
||||
payload = {"version": float("nan"), "events": [{"t": 0.0, "key": "C"}]}
|
||||
pak = _write_dir_sloppak(tmp_path, {"keys": "keys.json"}, payload)
|
||||
loaded = _load(pak, tmp_path)
|
||||
assert loaded.keys is not None
|
||||
assert loaded.keys["version"] == 1
|
||||
assert loaded.keys["events"] == [{"t": 0.0, "key": "C"}]
|
||||
@@ -148,6 +148,50 @@ def test_song_timeline_absent_when_path_escapes_sloppak(tmp_path: Path):
|
||||
assert loaded.song_timeline is None
|
||||
|
||||
|
||||
# ── tempos + time_signatures (feedpak 1.2.0) ─────────────────────────────────
|
||||
|
||||
def test_song_timeline_tempos_and_time_signatures_loaded(tmp_path: Path):
|
||||
payload = {
|
||||
"version": 1, "beats": [], "sections": [],
|
||||
"tempos": [{"time": 0.0, "bpm": 120}, {"time": 4.0, "bpm": 90}],
|
||||
"time_signatures": [{"time": 0.0, "ts": [4, 4]}, {"time": 8.0, "ts": [6, 8]}],
|
||||
}
|
||||
pak = _write_dir_sloppak(tmp_path, {"song_timeline": "song_timeline.json"}, payload)
|
||||
loaded = _load(pak, tmp_path)
|
||||
assert loaded.tempos == [{"time": 0.0, "bpm": 120.0}, {"time": 4.0, "bpm": 90.0}]
|
||||
assert loaded.time_signatures == [{"time": 0.0, "ts": [4, 4]},
|
||||
{"time": 8.0, "ts": [6, 8]}]
|
||||
|
||||
|
||||
def test_song_timeline_maps_absent_when_not_provided(tmp_path: Path):
|
||||
payload = {"version": 1, "beats": [], "sections": []}
|
||||
pak = _write_dir_sloppak(tmp_path, {"song_timeline": "song_timeline.json"}, payload)
|
||||
loaded = _load(pak, tmp_path)
|
||||
assert loaded.tempos is None and loaded.time_signatures is None
|
||||
|
||||
|
||||
def test_song_timeline_maps_sanitized(tmp_path: Path):
|
||||
payload = {
|
||||
"version": 1, "beats": [], "sections": [],
|
||||
"tempos": [{"time": 1.0, "bpm": 0}, {"time": 0.0, "bpm": 100}], # bpm 0 dropped + sorted
|
||||
"time_signatures": [{"time": 0.0, "ts": [4, 4, 4]}, # 3-long dropped
|
||||
{"time": 2.0, "ts": [3, 4]}],
|
||||
}
|
||||
pak = _write_dir_sloppak(tmp_path, {"song_timeline": "song_timeline.json"}, payload)
|
||||
loaded = _load(pak, tmp_path)
|
||||
assert loaded.tempos == [{"time": 0.0, "bpm": 100.0}]
|
||||
assert loaded.time_signatures == [{"time": 2.0, "ts": [3, 4]}]
|
||||
|
||||
|
||||
def test_song_timeline_maps_load_even_without_beats_or_sections(tmp_path: Path):
|
||||
# tempos/time_signatures are independent of beats/sections — a payload that
|
||||
# omits beats (invalid for the override path) must still surface the maps.
|
||||
payload = {"version": 1, "tempos": [{"time": 0.0, "bpm": 100}]}
|
||||
pak = _write_dir_sloppak(tmp_path, {"song_timeline": "song_timeline.json"}, payload)
|
||||
loaded = _load(pak, tmp_path)
|
||||
assert loaded.tempos == [{"time": 0.0, "bpm": 100.0}]
|
||||
|
||||
|
||||
def test_song_timeline_absent_when_path_is_absolute(tmp_path: Path):
|
||||
pak = _write_dir_sloppak(tmp_path, {"song_timeline": "/etc/passwd"}, None)
|
||||
loaded = _load(pak, tmp_path)
|
||||
|
||||
@@ -18,6 +18,7 @@ from song import (
|
||||
arrangement_to_wire,
|
||||
chord_from_wire,
|
||||
chord_to_wire,
|
||||
sanitize_tempos,
|
||||
compute_smart_names,
|
||||
note_from_wire,
|
||||
note_to_wire,
|
||||
@@ -168,6 +169,85 @@ def test_note_bend_nonzero_rounded_to_one_decimal():
|
||||
assert note_to_wire(n)["bn"] == 1.8
|
||||
|
||||
|
||||
# ── Bend shape (bt / bnv, §6.2.1) ────────────────────────────────────────────
|
||||
|
||||
def test_note_bend_shape_round_trip():
|
||||
"""A note with bend intent + a time-stamped curve survives the wire."""
|
||||
n = Note(
|
||||
time=0.5, string=0, fret=7, sustain=1.0,
|
||||
bend=2.0,
|
||||
bend_intent=4, # round-trip
|
||||
bend_values=[
|
||||
{"t": 0.0, "v": 0.0},
|
||||
{"t": 0.25, "v": 2.0},
|
||||
{"t": 0.5, "v": 0.0},
|
||||
],
|
||||
)
|
||||
wire = note_to_wire(n)
|
||||
assert wire["bt"] == 4
|
||||
assert wire["bnv"] == [
|
||||
{"t": 0.0, "v": 0.0},
|
||||
{"t": 0.25, "v": 2.0},
|
||||
{"t": 0.5, "v": 0.0},
|
||||
]
|
||||
assert note_from_wire(wire) == n
|
||||
|
||||
|
||||
def test_note_bend_shape_omitted_when_default():
|
||||
"""`bt`/`bnv` are default-omitted; absence decodes to 0 / None (not 0-present
|
||||
/ not [])."""
|
||||
wire = note_to_wire(Note(time=0.0, string=0, fret=0, bend=1.0))
|
||||
assert "bt" not in wire
|
||||
assert "bnv" not in wire
|
||||
decoded = note_from_wire(wire)
|
||||
assert decoded.bend_intent == 0
|
||||
assert decoded.bend_values is None
|
||||
|
||||
|
||||
def test_note_bend_values_rounded_on_wire():
|
||||
"""`bnv` rounds `t` to 3 and `v` to 1, matching the scalar `bn` precision."""
|
||||
n = Note(
|
||||
time=0.0, string=0, fret=0, bend=1.0, bend_intent=1,
|
||||
bend_values=[{"t": 0.123456, "v": 1.749}],
|
||||
)
|
||||
assert note_to_wire(n)["bnv"] == [{"t": 0.123, "v": 1.7}]
|
||||
|
||||
|
||||
def test_note_bend_values_sanitized_from_wire():
|
||||
"""Malformed `bnv` entries are dropped; bad/empty -> None; result sorted by t."""
|
||||
# NaN / non-dict / non-numeric entries dropped, remaining sorted by t.
|
||||
n = note_from_wire({
|
||||
"t": 0.0, "s": 0, "f": 0, "bn": 2.0,
|
||||
"bnv": [
|
||||
{"t": 0.5, "v": 2.0},
|
||||
{"t": 0.0, "v": 0.0},
|
||||
{"t": "x", "v": 1.0}, # non-numeric t -> dropped
|
||||
{"t": 0.25, "v": float("nan")}, # non-finite v -> dropped
|
||||
"garbage", # non-dict -> dropped
|
||||
],
|
||||
})
|
||||
assert n.bend_values == [{"t": 0.0, "v": 0.0}, {"t": 0.5, "v": 2.0}]
|
||||
# Empty / non-list / all-invalid collapse to None (never []).
|
||||
for bad in (None, [], "nope", [{"t": "a", "v": "b"}], [42]):
|
||||
assert note_from_wire(
|
||||
{"t": 0.0, "s": 0, "f": 0, "bnv": bad}).bend_values is None
|
||||
|
||||
|
||||
def test_chord_note_carries_bend_shape():
|
||||
"""Chord member notes inherit bt/bnv through chord_note_to_wire/chord_from_wire."""
|
||||
c = Chord(
|
||||
time=2.0, chord_id=0,
|
||||
notes=[Note(
|
||||
time=2.0, string=1, fret=5, bend=1.0, bend_intent=2,
|
||||
bend_values=[{"t": 0.0, "v": 1.0}, {"t": 0.3, "v": 0.0}],
|
||||
)],
|
||||
)
|
||||
decoded = chord_from_wire(chord_to_wire(c))
|
||||
cn = decoded.notes[0]
|
||||
assert cn.bend_intent == 2
|
||||
assert cn.bend_values == [{"t": 0.0, "v": 1.0}, {"t": 0.3, "v": 0.0}]
|
||||
|
||||
|
||||
# ── Chord round-trip ─────────────────────────────────────────────────────────
|
||||
|
||||
def test_chord_with_multiple_notes_round_trip():
|
||||
@@ -927,3 +1007,35 @@ def test_smart_names_arrangement_properties_defaults():
|
||||
assert arr.path_bass is False
|
||||
assert arr.bonus_arr is False
|
||||
assert arr.represent == 0
|
||||
|
||||
|
||||
# ── tempos (per-chart §6.10 + shared sanitizer) ──────────────────────────────
|
||||
|
||||
def test_sanitize_tempos_filters_sorts_and_coerces():
|
||||
assert sanitize_tempos([
|
||||
{"time": 2.0, "bpm": 90},
|
||||
{"time": 0.0, "bpm": 120},
|
||||
{"time": 1.0, "bpm": 0}, # bpm <= 0 -> dropped
|
||||
{"time": float("nan"), "bpm": 100}, # non-finite time -> dropped
|
||||
{"bpm": 100}, # missing time -> dropped
|
||||
{"time": 3.0, "bpm": float("inf")}, # non-finite bpm -> dropped
|
||||
"x", # non-dict -> dropped
|
||||
]) == [{"time": 0.0, "bpm": 120.0}, {"time": 2.0, "bpm": 90.0}]
|
||||
assert sanitize_tempos(None) == []
|
||||
assert sanitize_tempos("nope") == []
|
||||
|
||||
|
||||
def test_arrangement_tempos_round_trip_and_omitted_when_absent():
|
||||
arr = arrangement_from_wire({
|
||||
"name": "Bass", "tuning": [0, 0, 0, 0, 0, 0], "capo": 0,
|
||||
"tempos": [{"time": 0.0, "bpm": 60}, {"time": 2.0, "bpm": 120}],
|
||||
})
|
||||
assert arr.tempos == [{"time": 0.0, "bpm": 60.0}, {"time": 2.0, "bpm": 120.0}]
|
||||
assert arrangement_to_wire(arr)["tempos"] == \
|
||||
[{"time": 0.0, "bpm": 60.0}, {"time": 2.0, "bpm": 120.0}]
|
||||
|
||||
# Absent per-chart tempos -> None, and the wire key is OMITTED (not []),
|
||||
# so the chart follows the song-level tempo (spec §6.10).
|
||||
arr2 = arrangement_from_wire({"name": "Lead", "tuning": [0] * 6, "capo": 0})
|
||||
assert arr2.tempos is None
|
||||
assert "tempos" not in arrangement_to_wire(arr2)
|
||||
|
||||
Reference in New Issue
Block a user