mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-09-13 04:18:32 +00:00
feat(vocal-cal): Vocals path + input_setup vocal-calibration handoff
1. Add data/progression/paths/vocals.json (5 levels, instrument:vocals
challenges) so the Vocals tile appears on wizard step 4.
2. Add vocals to INSTRUMENTS in plugins/input_setup/screen.js (mode:audio)
so the wizard renders a Vocals panel when that path is selected.
3. In renderAudioPanel, branch vocals away from noteDetect.launchCalibration
to window.feedBack.vocalCalibration.launch({requester,onDone,onCancel}).
Guard: facade absent (vocal-highway plugin disabled) → shows a notice and
auto-advances via setTimeout; never hangs or throws.
4. Update test_bundled_content_loads_clean to expect the 'vocals' path id.
5. Add tests/js/vocal_calibration_handoff.test.js — 4 tests covering:
- vocals.json shape (id/icon/5 levels, namespaced challenge ids)
- INSTRUMENTS includes vocals
- fallback when facade absent (no hang/throw, auto-advance)
- facade.launch called with correct args, onDone resolves wizard
Facade contract: window.feedBack.vocalCalibration frozen {version:1,
launch({requester,onDone,onCancel})} — built by Dwight (vocal-highway).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H2bM5jSbMskpdxm2CmuQVj
This commit is contained in:
co-authored by
Claude Sonnet 4.6
parent
eef58c88c3
commit
95f4bb80e1
@@ -0,0 +1,128 @@
|
|||||||
|
{
|
||||||
|
"id": "vocals",
|
||||||
|
"name": "Vocals",
|
||||||
|
"icon": "vocals",
|
||||||
|
"order": 5,
|
||||||
|
"levels": [
|
||||||
|
{
|
||||||
|
"level": 1,
|
||||||
|
"required": 2,
|
||||||
|
"challenges": [
|
||||||
|
{
|
||||||
|
"id": "vocals.l1.first-phrase",
|
||||||
|
"title": "First Phrase",
|
||||||
|
"description": "Finish any vocal song with pitch detection on.",
|
||||||
|
"goal": { "type": "song_completed", "instrument": "vocals", "target": 1 }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "vocals.l1.clean-run",
|
||||||
|
"title": "Clean Run",
|
||||||
|
"description": "Score 80%+ accuracy on a vocal song.",
|
||||||
|
"goal": { "type": "song_completed", "instrument": "vocals", "min_accuracy": 0.8, "target": 1 }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "vocals.l1.daily-grind",
|
||||||
|
"title": "Daily Grind",
|
||||||
|
"description": "Complete 3 daily quests.",
|
||||||
|
"goal": { "type": "quest_completed", "period": "daily", "target": 3 }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"level": 2,
|
||||||
|
"required": 2,
|
||||||
|
"challenges": [
|
||||||
|
{
|
||||||
|
"id": "vocals.l2.five-songs",
|
||||||
|
"title": "Warming Up",
|
||||||
|
"description": "Finish 5 vocal songs.",
|
||||||
|
"goal": { "type": "song_completed", "instrument": "vocals", "target": 5 }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "vocals.l2.sharpshooter",
|
||||||
|
"title": "On Pitch",
|
||||||
|
"description": "Score 90%+ accuracy on a vocal song.",
|
||||||
|
"goal": { "type": "song_completed", "instrument": "vocals", "min_accuracy": 0.9, "target": 1 }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "vocals.l2.arcade-debut",
|
||||||
|
"title": "Arcade Debut",
|
||||||
|
"description": "Play 3 FeedBarcade rounds.",
|
||||||
|
"goal": { "type": "minigame_run", "target": 3 }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"level": 3,
|
||||||
|
"required": 2,
|
||||||
|
"challenges": [
|
||||||
|
{
|
||||||
|
"id": "vocals.l3.repertoire",
|
||||||
|
"title": "Repertoire",
|
||||||
|
"description": "Finish 10 different vocal songs.",
|
||||||
|
"goal": { "type": "song_completed", "instrument": "vocals", "distinct": true, "target": 10 }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "vocals.l3.consistent",
|
||||||
|
"title": "Consistent",
|
||||||
|
"description": "Score 85%+ accuracy on 5 vocal songs.",
|
||||||
|
"goal": { "type": "song_completed", "instrument": "vocals", "min_accuracy": 0.85, "target": 5 }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "vocals.l3.weekly-warrior",
|
||||||
|
"title": "Weekly Warrior",
|
||||||
|
"description": "Complete 2 weekly quests.",
|
||||||
|
"goal": { "type": "quest_completed", "period": "weekly", "target": 2 }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"level": 4,
|
||||||
|
"required": 2,
|
||||||
|
"challenges": [
|
||||||
|
{
|
||||||
|
"id": "vocals.l4.streak-week",
|
||||||
|
"title": "Seven-Day Streak",
|
||||||
|
"description": "Reach a 7-day play streak.",
|
||||||
|
"goal": { "type": "streak_reached", "days": 7 }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "vocals.l4.precision",
|
||||||
|
"title": "Pitch Perfect",
|
||||||
|
"description": "Score 95%+ accuracy on 3 vocal songs.",
|
||||||
|
"goal": { "type": "song_completed", "instrument": "vocals", "min_accuracy": 0.95, "target": 3 }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "vocals.l4.marathon",
|
||||||
|
"title": "Marathon",
|
||||||
|
"description": "Finish 25 vocal songs.",
|
||||||
|
"goal": { "type": "song_completed", "instrument": "vocals", "target": 25 }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"level": 5,
|
||||||
|
"required": 2,
|
||||||
|
"challenges": [
|
||||||
|
{
|
||||||
|
"id": "vocals.l5.collector",
|
||||||
|
"title": "Collector",
|
||||||
|
"description": "Earn 5,000 lifetime Decibels.",
|
||||||
|
"goal": { "type": "db_earned", "amount": 5000 }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "vocals.l5.virtuoso",
|
||||||
|
"title": "Virtuoso",
|
||||||
|
"description": "Score 95%+ accuracy on 10 different vocal songs.",
|
||||||
|
"goal": { "type": "song_completed", "instrument": "vocals", "min_accuracy": 0.95, "distinct": true, "target": 10 }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "vocals.l5.dedicated",
|
||||||
|
"title": "Dedicated",
|
||||||
|
"description": "Finish 50 vocal songs.",
|
||||||
|
"goal": { "type": "song_completed", "instrument": "vocals", "target": 50 }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -23,6 +23,7 @@
|
|||||||
const INSTRUMENTS = {
|
const INSTRUMENTS = {
|
||||||
guitar: { label: 'Guitar', mode: 'audio' },
|
guitar: { label: 'Guitar', mode: 'audio' },
|
||||||
bass: { label: 'Bass', mode: 'audio' },
|
bass: { label: 'Bass', mode: 'audio' },
|
||||||
|
vocals: { label: 'Vocals', mode: 'audio' },
|
||||||
keys: { label: 'Keys / Piano', mode: 'midi' },
|
keys: { label: 'Keys / Piano', mode: 'midi' },
|
||||||
piano: { label: 'Keys / Piano', mode: 'midi' },
|
piano: { label: 'Keys / Piano', mode: 'midi' },
|
||||||
drums: { label: 'Drums', mode: 'midi' },
|
drums: { label: 'Drums', mode: 'midi' },
|
||||||
@@ -163,7 +164,31 @@
|
|||||||
try { fetch('/api/settings', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ instrument: inst }) }); } catch (_) {}
|
try { fetch('/api/settings', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ instrument: inst }) }); } catch (_) {}
|
||||||
|
|
||||||
host.querySelector('[data-is-cal]').addEventListener('click', () => {
|
host.querySelector('[data-is-cal]').addEventListener('click', () => {
|
||||||
if (hasDetector) {
|
if (inst === 'vocals') {
|
||||||
|
// Vocals calibration is handled by the vocal-highway plugin's
|
||||||
|
// facade. Guard: if the facade is absent (plugin disabled or
|
||||||
|
// not yet loaded), mark done with a notice and continue.
|
||||||
|
const vc = window.feedBack && window.feedBack.vocalCalibration;
|
||||||
|
if (vc && vc.version === 1 && typeof vc.launch === 'function') {
|
||||||
|
const ov = document.getElementById('input-setup-overlay');
|
||||||
|
const prevDisplay = ov ? ov.style.display : '';
|
||||||
|
if (ov) ov.style.display = 'none';
|
||||||
|
const restore = () => {
|
||||||
|
const o = document.getElementById('input-setup-overlay');
|
||||||
|
if (o) o.style.display = prevDisplay;
|
||||||
|
};
|
||||||
|
vc.launch({
|
||||||
|
requester: 'input_setup',
|
||||||
|
onDone: (_result) => { restore(); advance(inst, true); },
|
||||||
|
onCancel: () => { restore(); /* stay on panel; user can skip or retry */ },
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// ponytail: vocal-highway not loaded — mark done so wizard doesn't hang
|
||||||
|
const body = host.querySelector('[data-is-body]');
|
||||||
|
if (body) body.innerHTML = '<p class="text-sm text-fb-textDim">Vocal calibration will be available once the Vocal Highway plugin is enabled. Continuing…</p>';
|
||||||
|
setTimeout(() => advance(inst, true), 1800);
|
||||||
|
}
|
||||||
|
} else if (hasDetector) {
|
||||||
// Hide our own full-screen overlay while note_detect's
|
// Hide our own full-screen overlay while note_detect's
|
||||||
// Calibration Wizard runs on top. That wizard goes
|
// Calibration Wizard runs on top. That wizard goes
|
||||||
// transparent (pointer-events:none) when it minimizes to
|
// transparent (pointer-events:none) when it minimizes to
|
||||||
|
|||||||
@@ -0,0 +1,243 @@
|
|||||||
|
// Tests: vocals path + input_setup vocal-calibration handoff.
|
||||||
|
//
|
||||||
|
// Failure input for INSTRUMENTS absence: instrument id 'vocals' not in the map
|
||||||
|
// → wizard queue filters it out and the step is silently skipped.
|
||||||
|
// Failure input for facade absent: window.feedBack.vocalCalibration undefined
|
||||||
|
// → Calibrate click must NOT throw and must call advance (via setTimeout).
|
||||||
|
// Failure input for facade present: vocalCalibration.launch never called
|
||||||
|
// → missed the vocals branch, fell through to noteDetect path.
|
||||||
|
|
||||||
|
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 ROOT = path.join(__dirname, '..', '..');
|
||||||
|
const SCREEN_JS = path.join(ROOT, 'plugins', 'input_setup', 'screen.js');
|
||||||
|
const VOCALS_PATH = path.join(ROOT, 'data', 'progression', 'paths', 'vocals.json');
|
||||||
|
|
||||||
|
// ── helpers ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function makeEl(tag, attrs) {
|
||||||
|
const el = {
|
||||||
|
tagName: (tag || 'DIV').toUpperCase(),
|
||||||
|
id: (attrs && attrs.id) || '',
|
||||||
|
className: '',
|
||||||
|
innerHTML: '',
|
||||||
|
textContent: '',
|
||||||
|
disabled: false,
|
||||||
|
hidden: false,
|
||||||
|
style: {},
|
||||||
|
children: [],
|
||||||
|
__handlers: {},
|
||||||
|
getAttribute(a) { return this[a] != null ? String(this[a]) : null; },
|
||||||
|
setAttribute(a, v) { this[a] = v; },
|
||||||
|
addEventListener(type, fn) {
|
||||||
|
(this.__handlers[type] || (this.__handlers[type] = [])).push(fn);
|
||||||
|
},
|
||||||
|
click() { (this.__handlers.click || []).forEach((fn) => fn()); },
|
||||||
|
_fire(type, arg) { (this.__handlers[type] || []).forEach((fn) => fn(arg)); },
|
||||||
|
appendChild(child) { this.children.push(child); return child; },
|
||||||
|
remove() {},
|
||||||
|
querySelector(sel) { return _qs(this, sel); },
|
||||||
|
querySelectorAll(sel) { const h = _qs(this, sel); return h ? [h] : []; },
|
||||||
|
get value() { return this._value || ''; },
|
||||||
|
set value(v) { this._value = v; },
|
||||||
|
};
|
||||||
|
// Rebuild querySelector list on innerHTML set via a simple data-attr stub.
|
||||||
|
// We patch innerHTML so the wizard's shell() and body replacements work.
|
||||||
|
let _html = '';
|
||||||
|
Object.defineProperty(el, 'innerHTML', {
|
||||||
|
get() { return _html; },
|
||||||
|
set(v) {
|
||||||
|
_html = v;
|
||||||
|
// Harvest known data-attrs the wizard queries after setting innerHTML.
|
||||||
|
el._namedChildren = {};
|
||||||
|
const RE = /data-([\w-]+)/g;
|
||||||
|
let m;
|
||||||
|
while ((m = RE.exec(v)) !== null) {
|
||||||
|
const key = m[1];
|
||||||
|
if (!el._namedChildren[key]) {
|
||||||
|
const child = makeEl('div', {});
|
||||||
|
child._attr = `data-${key}`;
|
||||||
|
child.className = '';
|
||||||
|
el._namedChildren[key] = child;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return el;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _qs(el, sel) {
|
||||||
|
// Support [data-is-*] selectors used by the wizard.
|
||||||
|
const m = sel.match(/^\[data-([\w-]+)\]$/);
|
||||||
|
if (!m) return null;
|
||||||
|
if (el._namedChildren) return el._namedChildren[m[1]] || null;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeDocument(extraById) {
|
||||||
|
const byId = Object.assign({}, extraById || {});
|
||||||
|
return {
|
||||||
|
createElement(tag) { return makeEl(tag, {}); },
|
||||||
|
getElementById(id) { return byId[id] || null; },
|
||||||
|
body: makeEl('body', {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadScreenJs(windowOverrides) {
|
||||||
|
const code = fs.readFileSync(SCREEN_JS, 'utf8');
|
||||||
|
// The IIFE runs in the global scope — bare `document`, `setTimeout`, etc.
|
||||||
|
// must be top-level context properties, not nested under `window`.
|
||||||
|
const doc = (windowOverrides && windowOverrides.document) || makeDocument();
|
||||||
|
let _timeoutFn = null;
|
||||||
|
const timeoutImpl = (windowOverrides && windowOverrides.setTimeout)
|
||||||
|
|| function(fn, _ms) { fn(); };
|
||||||
|
|
||||||
|
const ctx = {
|
||||||
|
window: Object.assign({
|
||||||
|
feedBack: {
|
||||||
|
capabilities: null,
|
||||||
|
midiInput: null,
|
||||||
|
vocalCalibration: undefined,
|
||||||
|
},
|
||||||
|
feedBackInputSetup: undefined,
|
||||||
|
noteDetect: undefined,
|
||||||
|
}, windowOverrides),
|
||||||
|
document: doc,
|
||||||
|
localStorage: (() => {
|
||||||
|
const store = {};
|
||||||
|
return {
|
||||||
|
getItem(k) { return store[k] != null ? store[k] : null; },
|
||||||
|
setItem(k, v) { store[k] = String(v); },
|
||||||
|
removeItem(k) { delete store[k]; },
|
||||||
|
};
|
||||||
|
})(),
|
||||||
|
fetch() { return Promise.resolve({ ok: true }); },
|
||||||
|
setTimeout: timeoutImpl,
|
||||||
|
console,
|
||||||
|
};
|
||||||
|
vm.createContext(ctx);
|
||||||
|
vm.runInContext(code, ctx);
|
||||||
|
return ctx.window;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 1. vocals.json shape ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
test('vocals.json exists and has correct id, name, icon, and 5 levels', () => {
|
||||||
|
const raw = fs.readFileSync(VOCALS_PATH, 'utf8');
|
||||||
|
const json = JSON.parse(raw);
|
||||||
|
assert.equal(json.id, 'vocals');
|
||||||
|
assert.equal(json.name, 'Vocals');
|
||||||
|
assert.ok(json.icon, 'icon field must be present');
|
||||||
|
assert.equal(typeof json.order, 'number');
|
||||||
|
assert.equal(json.levels.length, 5);
|
||||||
|
// Every challenge id must be namespaced under vocals.*
|
||||||
|
for (const lvl of json.levels) {
|
||||||
|
assert.ok(Number.isInteger(lvl.level), 'level must be integer');
|
||||||
|
assert.ok(Array.isArray(lvl.challenges), 'challenges must be array');
|
||||||
|
for (const ch of lvl.challenges) {
|
||||||
|
assert.ok(ch.id.startsWith('vocals.'), `challenge id must start with vocals.: ${ch.id}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── 2. INSTRUMENTS map contains vocals ───────────────────────────────────────
|
||||||
|
|
||||||
|
test('screen.js INSTRUMENTS includes vocals with mode audio', () => {
|
||||||
|
const w = loadScreenJs();
|
||||||
|
// feedBackInputSetup.status should return a status object including vocals.
|
||||||
|
const status = w.feedBackInputSetup.status(['vocals']);
|
||||||
|
assert.ok('vocals' in status, 'vocals must appear in status output — meaning INSTRUMENTS has it');
|
||||||
|
assert.equal(status.vocals, 'needs-setup', 'fresh window → vocals needs-setup');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── 3. vocalCalibration facade absent → fallback notice, no throw ─────────────
|
||||||
|
|
||||||
|
test('renderAudioPanel for vocals falls back gracefully when vocalCalibration is absent', async () => {
|
||||||
|
// Override setTimeout to capture the delay+advance without waiting.
|
||||||
|
let timeoutFn = null;
|
||||||
|
const w = loadScreenJs({
|
||||||
|
feedBack: {
|
||||||
|
capabilities: null,
|
||||||
|
midiInput: null,
|
||||||
|
vocalCalibration: undefined, // absent
|
||||||
|
},
|
||||||
|
// setTimeout override — loadScreenJs reads it as the top-level ctx.setTimeout.
|
||||||
|
setTimeout(fn, _ms) { timeoutFn = fn; },
|
||||||
|
});
|
||||||
|
|
||||||
|
// We need a host element. wire a minimal one.
|
||||||
|
const hostEl = makeEl('div', {});
|
||||||
|
|
||||||
|
// We'll call mount() directly — it returns a promise that resolves to
|
||||||
|
// {completed, skipped}. Because capabilities is null, the domain owner
|
||||||
|
// registration is skipped, and the wizard just renders panels.
|
||||||
|
const mountPromise = w.feedBackInputSetup.mount(hostEl, { instruments: ['vocals'] });
|
||||||
|
|
||||||
|
// At this point renderAudioPanel has been called, which is async — wait a tick.
|
||||||
|
await new Promise((r) => setImmediate(r));
|
||||||
|
|
||||||
|
// The shell sets innerHTML which populates _namedChildren with data-is-cal.
|
||||||
|
const calBtn = hostEl._namedChildren && hostEl._namedChildren['is-cal'];
|
||||||
|
assert.ok(calBtn, 'Calibrate button must be rendered for vocals audio panel');
|
||||||
|
|
||||||
|
// Click the Calibrate button — should NOT throw.
|
||||||
|
assert.doesNotThrow(() => calBtn.click());
|
||||||
|
|
||||||
|
// A setTimeout should have been scheduled (the fallback path), and the
|
||||||
|
// data-is-body should now hold the notice text.
|
||||||
|
assert.ok(timeoutFn, 'fallback must schedule a setTimeout to auto-advance');
|
||||||
|
|
||||||
|
// Fire the timeout — advances the wizard which resolves the promise.
|
||||||
|
timeoutFn();
|
||||||
|
const result = await mountPromise;
|
||||||
|
// Compare primitives to avoid cross-realm Array.prototype issues (VM context).
|
||||||
|
assert.equal(result.completed.length, 1, 'fallback must mark vocals completed');
|
||||||
|
assert.equal(result.completed[0], 'vocals', 'completed[0] must be vocals');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── 4. vocalCalibration facade present → launch() called, not noteDetect ──────
|
||||||
|
|
||||||
|
test('renderAudioPanel for vocals calls vocalCalibration.launch when facade present', async () => {
|
||||||
|
let launchArgs = null;
|
||||||
|
const facade = {
|
||||||
|
version: 1,
|
||||||
|
launch(args) { launchArgs = args; },
|
||||||
|
};
|
||||||
|
|
||||||
|
const w = loadScreenJs({
|
||||||
|
feedBack: {
|
||||||
|
capabilities: null,
|
||||||
|
midiInput: null,
|
||||||
|
vocalCalibration: facade,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
let noteDetectCalled = false;
|
||||||
|
w.noteDetect = {
|
||||||
|
launchCalibration() { noteDetectCalled = true; },
|
||||||
|
};
|
||||||
|
|
||||||
|
const hostEl = makeEl('div', {});
|
||||||
|
const mountPromise = w.feedBackInputSetup.mount(hostEl, { instruments: ['vocals'] });
|
||||||
|
await new Promise((r) => setImmediate(r));
|
||||||
|
|
||||||
|
const calBtn = hostEl._namedChildren && hostEl._namedChildren['is-cal'];
|
||||||
|
assert.ok(calBtn, 'Calibrate button must be rendered');
|
||||||
|
calBtn.click();
|
||||||
|
|
||||||
|
assert.ok(launchArgs, 'vocalCalibration.launch must have been called');
|
||||||
|
assert.equal(launchArgs.requester, 'input_setup');
|
||||||
|
assert.equal(typeof launchArgs.onDone, 'function');
|
||||||
|
assert.equal(typeof launchArgs.onCancel, 'function');
|
||||||
|
assert.equal(noteDetectCalled, false, 'noteDetect.launchCalibration must NOT be called for vocals');
|
||||||
|
|
||||||
|
// Simulate the facade calling onDone to settle the promise.
|
||||||
|
launchArgs.onDone({ latencyMs: 12, range: null, noiseFloorDb: null, micStatus: 'ok' });
|
||||||
|
const result = await mountPromise;
|
||||||
|
assert.equal(result.completed.length, 1);
|
||||||
|
assert.equal(result.completed[0], 'vocals');
|
||||||
|
});
|
||||||
@@ -33,7 +33,7 @@ BUNDLED_CONTENT = REPO_ROOT / "data" / "progression"
|
|||||||
def test_bundled_content_loads_clean():
|
def test_bundled_content_loads_clean():
|
||||||
content, warnings = load_content(BUNDLED_CONTENT)
|
content, warnings = load_content(BUNDLED_CONTENT)
|
||||||
assert warnings == []
|
assert warnings == []
|
||||||
assert set(content["paths"]) == {"guitar", "bass", "drums", "keys"}
|
assert set(content["paths"]) == {"guitar", "bass", "drums", "keys", "vocals"}
|
||||||
assert content["challenge_index"]
|
assert content["challenge_index"]
|
||||||
assert content["quests"]["daily"]["count"] == 3
|
assert content["quests"]["daily"]["count"] == 3
|
||||||
assert content["quests"]["weekly"]["count"] == 2
|
assert content["quests"]["weekly"]["count"] == 2
|
||||||
|
|||||||
Reference in New Issue
Block a user