Band Lineup Display, the backstage app itself
The Electron app that runs on the backstage PC: stage lineup photos with mic/pack labels, packaged for Windows. It also carries the ProPresenter Now/Next feature in two forms that share one engine — propresenter-core, where every ProPresenter 7/20 quirk lives, and propresenter-app, the standalone display it serves. Until now this existed only on the production machine and as a read-only snapshot inside the ProdDash repo. Source only: the 231 MB dist/ is build output, and credentials, photos and processed images are written to the Electron userData directory, never here. tmp/pp-mock.js stays — it is the ProPresenter mock ProdDash's own CLAUDE.md tells people to run. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,139 @@
|
||||
/**
|
||||
* Verifies the ProPresenter data pipeline used by main.js against tmp/pp-mock.js.
|
||||
* Mirrors the main-process client logic (stream parsing, slide-count summing,
|
||||
* active-item matching, NOW/NEXT assembly) and asserts the resulting view-model.
|
||||
*
|
||||
* node tmp/pp-verify.js [port]
|
||||
*/
|
||||
const HOST = '127.0.0.1';
|
||||
const PORT = Number.parseInt(process.argv[2] || '1599', 10);
|
||||
|
||||
let failures = 0;
|
||||
function check(label, cond) {
|
||||
console.log(`${cond ? 'PASS' : 'FAIL'} ${label}`);
|
||||
if (!cond) failures += 1;
|
||||
}
|
||||
|
||||
// ---- client logic mirrored from main.js ----
|
||||
const slideCountCache = new Map();
|
||||
let state = { reachable: false, playlistUuid: '', playlistName: '', items: [], activeUuid: '', activeName: '', activeIndex: -1, currentSlide: 0 };
|
||||
|
||||
function ppUrl(target) { return `http://${HOST}:${PORT}${target}`; }
|
||||
async function ppRequestJson(target, init) {
|
||||
const r = await fetch(ppUrl(target), { headers: { Accept: 'application/json' }, ...init });
|
||||
if (!r.ok) throw new Error(`${r.status}`);
|
||||
const t = await r.text();
|
||||
return t ? JSON.parse(t) : null;
|
||||
}
|
||||
async function ppSlideCount(uuid) {
|
||||
if (!uuid) return null;
|
||||
if (slideCountCache.has(uuid)) return slideCountCache.get(uuid);
|
||||
try {
|
||||
const data = await ppRequestJson(`/v1/presentation/${encodeURIComponent(uuid)}`);
|
||||
let count = 0;
|
||||
for (const g of data?.groups || []) for (const s of g?.slides || []) if (s?.enabled !== false) count += 1;
|
||||
slideCountCache.set(uuid, count);
|
||||
return count;
|
||||
} catch { return null; }
|
||||
}
|
||||
function recomputeActiveIndex() {
|
||||
state.activeIndex = state.activeUuid ? state.items.findIndex((it) => it.uuid && it.uuid === state.activeUuid) : -1;
|
||||
}
|
||||
async function refreshCurrentPlaylist() {
|
||||
const cur = await ppRequestJson('/v1/playlist/current');
|
||||
state.playlistUuid = String(cur?.uuid || '');
|
||||
state.playlistName = String(cur?.name || '');
|
||||
if (!state.playlistUuid) { state.items = []; recomputeActiveIndex(); return; }
|
||||
const data = await ppRequestJson(`/v1/playlist/${encodeURIComponent(state.playlistUuid)}`);
|
||||
state.items = (data?.items || []).map((it) => ({
|
||||
uuid: String(it?.id?.uuid || ''), name: String(it?.id?.name || ''), type: String(it?.type || ''),
|
||||
isHidden: Boolean(it?.is_hidden), isPco: Boolean(it?.is_pco), slideCount: null,
|
||||
}));
|
||||
recomputeActiveIndex();
|
||||
}
|
||||
async function fillSlideCounts() {
|
||||
for (const it of state.items) if (it.type === 'presentation' && it.uuid && it.slideCount === null) it.slideCount = await ppSlideCount(it.uuid);
|
||||
}
|
||||
function applySlideIndex(data) {
|
||||
const pres = data?.presentation;
|
||||
if (!pres || !pres.presentation_id) { state.activeUuid = ''; state.activeName = ''; state.currentSlide = 0; recomputeActiveIndex(); return; }
|
||||
state.currentSlide = Number.isInteger(pres.index) ? pres.index + 1 : 0;
|
||||
state.activeUuid = String(pres.presentation_id.uuid || '');
|
||||
state.activeName = String(pres.presentation_id.name || '');
|
||||
recomputeActiveIndex();
|
||||
}
|
||||
async function viewModel() {
|
||||
let total = 0;
|
||||
if (state.activeUuid) { const c = slideCountCache.get(state.activeUuid); total = Number.isInteger(c) ? c : (await ppSlideCount(state.activeUuid)) || 0; }
|
||||
return { reachable: state.reachable, playlistName: state.playlistName, items: state.items, activeIndex: state.activeIndex, activePresentationName: state.activeName, currentSlide: state.currentSlide, currentSlideTotal: total };
|
||||
}
|
||||
function describe(vm) {
|
||||
const now = vm.activeIndex >= 0 ? vm.items[vm.activeIndex].name : vm.activePresentationName;
|
||||
const from = vm.activeIndex >= 0 ? vm.activeIndex + 1 : 0;
|
||||
const next = vm.items.slice(from).filter((it) => !it.isHidden)
|
||||
.map((it) => it.type === 'presentation' ? `${it.name} (${it.slideCount})` : `${it.name} [${it.type}]`);
|
||||
return `NOW: ${now} — slide ${vm.currentSlide}/${vm.currentSlideTotal}\n NEXT: ${next.join(' · ')}`;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
await refreshCurrentPlaylist();
|
||||
applySlideIndex(await ppRequestJson('/v1/presentation/slide_index'));
|
||||
await fillSlideCounts();
|
||||
state.reachable = true;
|
||||
const vm = await viewModel();
|
||||
console.log('\n--- initial view-model ---\n' + describe(vm) + '\n');
|
||||
|
||||
const byName = (n) => vm.items.find((it) => it.name === n);
|
||||
check('playlist name', vm.playlistName === 'Sunday AM — June 1');
|
||||
check('item count = 8', vm.items.length === 8);
|
||||
check('Welcome slides = 3', byName('Welcome').slideCount === 3);
|
||||
check('Amazing Grace slides = 12', byName('Amazing Grace').slideCount === 12);
|
||||
check('Sermon slides = 5', byName('Sermon: Living Hope').slideCount === 5);
|
||||
check('Closing slides = 8', byName('Closing Song').slideCount === 8);
|
||||
check('Benediction slides = 2', byName('Benediction').slideCount === 2);
|
||||
check('media item has no count', byName('Bumper Video').slideCount === null);
|
||||
check('header item has no count', byName('Pre-Service Loop').slideCount === null);
|
||||
check('active is Amazing Grace', vm.activeIndex === vm.items.findIndex((it) => it.name === 'Amazing Grace'));
|
||||
check('current slide >= 1', vm.currentSlide >= 1);
|
||||
check('current total = 12', vm.currentSlideTotal === 12);
|
||||
const from = vm.activeIndex + 1;
|
||||
const nextNames = vm.items.slice(from).filter((it) => !it.isHidden).map((it) => it.name);
|
||||
check('NEXT excludes hidden cue', !nextNames.includes('Hidden Cue'));
|
||||
check('NEXT starts with Sermon', nextNames[0] === 'Sermon: Living Hope');
|
||||
|
||||
// Now exercise the live chunked stream for a few updates.
|
||||
console.log('\n--- streaming live updates ---');
|
||||
setTimeout(() => { console.error('TIMEOUT waiting for stream updates'); process.exit(3); }, 8000);
|
||||
const res = await fetch(ppUrl('/v1/status/updates'), {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||
body: JSON.stringify(['presentation/slide_index', 'playlist/current']),
|
||||
});
|
||||
check('stream opened (chunked)', res.ok && !!res.body);
|
||||
let updates = 0;
|
||||
const seenSlides = [];
|
||||
let buffer = '';
|
||||
const decoder = new TextDecoder();
|
||||
for await (const chunk of res.body) {
|
||||
buffer += decoder.decode(chunk, { stream: true });
|
||||
let nl;
|
||||
while ((nl = buffer.indexOf('\n')) >= 0) {
|
||||
const line = buffer.slice(0, nl).trim();
|
||||
buffer = buffer.slice(nl + 1);
|
||||
if (!line) continue;
|
||||
const msg = JSON.parse(line);
|
||||
if (String(msg.url).includes('presentation/slide_index')) {
|
||||
applySlideIndex(msg.data);
|
||||
seenSlides.push(state.currentSlide);
|
||||
console.log(describe(await viewModel()).split('\n')[0]);
|
||||
updates += 1;
|
||||
}
|
||||
}
|
||||
if (updates >= 4) break;
|
||||
}
|
||||
check('received >= 4 live slide updates', updates >= 4);
|
||||
check('slide value changed across updates', new Set(seenSlides).size >= 2);
|
||||
|
||||
console.log(`\n${failures === 0 ? '✅ ALL CHECKS PASSED' : `❌ ${failures} CHECK(S) FAILED`}`);
|
||||
process.exit(failures === 0 ? 0 : 1);
|
||||
}
|
||||
main().catch((e) => { console.error('verify error:', e); process.exit(2); });
|
||||
Reference in New Issue
Block a user