Files
christianandClaude Opus 5.5 1fd7dcdf1e Band Lineup 1.13.0: a photo per instrument, picked by the spot each person is in that week
DJ plays drums one week and sings the next; one photo can't fit both. A
person can now have a photo per instrument, named "Name - Instrument"
("DJ - Drums.jpg", "DJ - Vocals.jpg"), plus a plain "DJ.jpg" for
everything else. Sync reads the instrument family of the spot each person
lands in (its bracketed role, else their PCO position) and picks:

1. the photo picked by hand for them on that instrument;
2. a photo named for that instrument;
3. their everyday pick, else their best-named photo. A photo of them on
   another instrument still beats PCO's headshot.

Families keep the shorthands together: Vox/Vocals/Singer, Keys/Piano/Pad,
Drums/Perc, Bass, Guitar/Electric/Acoustic/Rhythm, Leader. A photo
dropped onto someone is remembered per person per instrument
("<id>|drums"), so a drum pick never overrides the vocal photo. A spot
naming several roles files under its most specific: Leader, then Vocals,
Drums, Keys, Bass, Guitar. Photo names from files now collapse the spaces
left by "Name - Instrument".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 13:40:00 -04:00

144 lines
9.8 KiB
JavaScript

'use strict';
const assert = require('assert');
const { assignMembersToSlots, isAllowedTeamName, slotRoleText, scoreMatch } = require('./matching');
const SLOTS = [
'[LEADER, VOX 1, ACOUSTIC] MIX 1', '[VOX 2] MIX 2', '[VOX 3] MIX 3', '[VOX 4] MIX 4',
'[GUITAR, LEAD] MIX 5', '[BASS] MIX 6', '[VOX 5] MIX 7', '[PIANO]', '[DRUMS]',
'[KEYS 2]', '[CALL TO WORSHIP] MC2', 'STAGE MANAGER', '[MC] MC1', '[SPEAKER] HEADSET',
];
const M = (label) => ({ label, name: label });
const members = [
M('Vox 3'), M('Vox 2'), M('Vox 1'), M('Guitar / Lead'), M('Bass'), M('Piano'), M('Drums'), M('MC'), M('Keys 2'),
];
const out = assignMembersToSlots(SLOTS, members).map((m) => (m ? m.label : null));
// Numbered vocals land in the RIGHT slot (this is the swap bug we fixed).
assert.strictEqual(out[0], 'Vox 1', 'slot 0 → Vox 1');
assert.strictEqual(out[1], 'Vox 2', 'slot 1 → Vox 2');
assert.strictEqual(out[2], 'Vox 3', 'slot 2 → Vox 3');
assert.strictEqual(out[3], null, 'slot 3 (Vox 4) unfilled — nobody scheduled');
assert.strictEqual(out[4], 'Guitar / Lead', 'slot 4 → Guitar/Lead');
assert.strictEqual(out[5], 'Bass', 'slot 5 → Bass');
assert.strictEqual(out[6], null, 'slot 6 (Vox 5) unfilled');
assert.strictEqual(out[7], 'Piano', 'slot 7 → Piano');
assert.strictEqual(out[8], 'Drums', 'slot 8 → Drums');
assert.strictEqual(out[9], 'Keys 2', 'slot 9 → Keys 2');
assert.strictEqual(out[12], 'MC', 'slot 12 → MC');
// A different number never matches.
assert.strictEqual(scoreMatch(slotRoleText('[VOX 2] MIX 2'), 'Vox 3'), 0, 'Vox 3 must not match Vox 2 slot');
// Mic number no longer pollutes: Vox 7 must not grab "[VOX 5] MIX 7".
assert.strictEqual(scoreMatch(slotRoleText('[VOX 5] MIX 7'), 'Vox 7'), 0, 'Vox 7 must not match the MIX 7 slot');
// Team allow-list (singular stems match plurals/variants via substring).
assert.ok(isAllowedTeamName('Band', ['band', 'vocal']));
assert.ok(isAllowedTeamName('Vocals', ['band', 'vocal']));
assert.ok(isAllowedTeamName('Vocal Team', ['band', 'vocal']));
assert.ok(!isAllowedTeamName('Video', ['band', 'vocal', 'production']));
console.log('matching.test.js: all assertions passed ✓');
console.log('assignment:', JSON.stringify(out));
// --- bestLibraryMatch: folder-photo → PCO person by name ---
{
const { bestLibraryMatch } = require('./matching');
const name = (n) => (e) => (e ? e.name : null);
const lib = [
{ id: 'a', name: 'Alex' }, { id: 'g', name: 'AlexG' }, { id: 'bm', name: 'Brian M' },
{ id: 'c', name: 'Courtney' }, { id: 'gn', name: 'Gaitan' }, { id: 'wes', name: 'Wes' },
];
const pick = (n, list = lib) => { const e = bestLibraryMatch(n, list); return e ? e.name : null; };
assert.strictEqual(pick('Alex Gaitan'), 'Alex', 'first name matches "Alex"');
assert.strictEqual(pick('Brian Moniz'), 'Brian M', '"Brian M" matches "Brian Moniz"');
assert.strictEqual(pick('Courtney Maranda'), 'Courtney', 'first name matches');
assert.strictEqual(pick('Alexander Zinni'), null, '"Alex" must NOT grab "Alexander Zinni"');
assert.strictEqual(pick('Wes Carter'), 'Wes', 'first name matches "Wes"');
// surname-named photo
assert.strictEqual(pick('Someone Gaitan'), 'Gaitan', 'surname photo matches by last name');
// two people share a first name + only a first-name photo → ambiguous only if two matches tie;
// here a single "Alex" photo still maps to whoever's first name is exactly Alex.
assert.strictEqual(pick('Alex Johnson'), 'Alex', 'first-name photo maps to any exact-first-name person');
// exact full name beats a first-name photo
assert.strictEqual(pick('Alex Gaitan', [{ id: 'x', name: 'Alex Gaitan' }, { id: 'a', name: 'Alex' }]), 'Alex Gaitan', 'exact full name wins');
// two equally good photos ("Mike" and "mike") → still one of the booth's photos, never PCO's
assert.strictEqual(bestLibraryMatch('Mike Powell', [{ id: '1', name: 'Mike' }, { id: '2', name: 'mike' }]).id, '1', 'tie → first library photo');
assert.strictEqual(bestLibraryMatch('Joram Lopez', lib), null, 'no photo of them → PCO headshot');
console.log('bestLibraryMatch: all assertions passed ✓');
}
// --- one spot per person: someone scheduled twice (Leader + Acoustic) fills one spot only ---
{
const P = (label, name, personId) => ({ label, name, personId });
const slots = ['[LEADER, VOX 1, ACOUSTIC] MIX 1', '[GUITAR, 2] MIX 7'];
const got = assignMembersToSlots(slots, [P('[Leader]', 'Mike', 'mp'), P('Guitar [Acoustic]', 'Mike', 'mp'), P('Guitar [Rhythm]', 'Craig', 'cs')]).map((m) => (m ? m.name : null));
assert.deepStrictEqual(got, ['Mike', 'Craig'], 'Mike once, Craig gets the guitar spot');
console.log('one spot per person: all assertions passed ✓');
}
// --- Planning Center notes place the leader and vocalists (real notes from Oct 25 / Nov 1, 2026) ---
{
const { noteScore } = require('./matching');
const NA_SLOTS = [
'[LEADER, VOX 1, ACOUSTIC] MIX 1', '[VOX 2] MIX 2', '[VOX 3] MIX 3', '[VOX 4] MIX 4', '[GUITAR, LEAD] MIX 5',
'[BASS] MIX 6', '[GUITAR, 2] MIX 7', '[VOX 5] MIX 8', '[PIANO]', '[DRUMS]', '[KEYS 2]',
];
const P = (label, name, personId, note) => ({ label, name, personId, note });
// Listed in the order Planning Center returned them — vocalists alphabetical, not by mic.
const oct25 = [
P('Vocalist', 'DJ', 'dj', 'Mix 3, VOX 3'), P('[Leader]', 'Mike', 'mp', 'Mix 1, VOX 1'), P('Guitar [Lead]', 'Yeremis', 'yc'),
P('Piano/Pad', 'Kevin', 'km'), P('Vocalist', 'Genesis', 'gg', 'Mix 4, VOX 4'), P('Guitar [Rhythm]', 'Craig', 'cs'),
P('Guitar [Acoustic]', 'Mike', 'mp'), P('Vocalist', 'Rosaliz', 'rg', 'Mix 2, VOX 2'), P('Drums', 'Luis', 'ld'),
P('Vocalist', 'Bianca', 'bb', 'Mix 8, VOX 5'), P('Guitar [Bass]', 'Joram', 'jl'),
];
const got = assignMembersToSlots(NA_SLOTS, oct25).map((m) => (m ? m.name : null));
assert.deepStrictEqual(got, ['Mike', 'Rosaliz', 'DJ', 'Genesis', 'Yeremis', 'Joram', 'Craig', 'Bianca', 'Kevin', 'Luis', null], 'Oct 25 by notes');
// Nov 1: leader note in different case, a drum note with no mix, an "NA" note.
const nov1 = [P('Guitar [Acoustic]', 'Mike', 'mp', 'NA'), P('Drums', 'Alex', 'ag', 'DP48, Drums'), P('[Leader]', 'Mike', 'mp', 'Mix 1, Vox 1')];
assert.deepStrictEqual(assignMembersToSlots(NA_SLOTS, nov1).map((m) => (m ? m.name : null)).filter(Boolean), ['Mike', 'Alex'], 'Nov 1: Mike once, Alex on drums');
assert.strictEqual(assignMembersToSlots(NA_SLOTS, nov1)[9].name, 'Alex', 'DP48, Drums → [DRUMS]');
// A note never pulls someone onto a different mix.
assert.strictEqual(noteScore('[VOX 2] MIX 2', 'Mix 8, VOX 5'), 0, 'Mix 8 note must not match MIX 2');
assert.strictEqual(noteScore('[VOX 5] MIX 8', 'Mix 8, VOX 5') > 0, true, 'Mix 8, VOX 5 matches [VOX 5] MIX 8');
assert.strictEqual(noteScore('[VOX 5] MIX 8', 'NA'), 0, 'NA says nothing');
assert.strictEqual(noteScore('[VOX 1] MIX 10', 'Mix 1, VOX 1') , 0, 'Mix 1 must not match MIX 10');
console.log('notes: all assertions passed ✓');
}
// --- samePerson: is a dropped photo of the person already in the spot? ---
{
const { samePerson } = require('./matching');
assert.ok(samePerson('Rosaliz Gaitan', 'Rose'), 'nickname: Rose → Rosaliz');
assert.ok(samePerson('Mike Powell', 'mike'), 'first name, any case');
assert.ok(samePerson('Mike Powell', 'Mike Powell action'), 'full name plus words');
assert.ok(samePerson('DJ McLean', 'DJ action'), 'short first name plus words');
assert.ok(!samePerson('Mike Powell', 'Craig'), 'a different person is a replacement, not their photo');
assert.ok(!samePerson('Al Smith', 'Alex'), 'two-letter names never match by prefix');
console.log('samePerson: all assertions passed ✓');
}
// --- a photo per instrument: "DJ - Drums", "DJ - Vocals", plain "DJ" for everything else ---
{
const { bestLibraryMatch, photoParts, spotFamilies, roleFamilies, photoKey } = require('./matching');
// File names arrive with the dash turned to a space: "DJ - Drums.jpg" → "DJ Drums".
const lib = [{ id: 'e', name: 'DJ' }, { id: 'd', name: 'DJ Drums' }, { id: 'v', name: 'DJ Vocals' }, { id: 'k', name: 'Kevin Piano' }, { id: 'm', name: 'Mike Powell Leader' }];
const pick = (name, slot, pos) => { const e = bestLibraryMatch(name, lib, spotFamilies(slot, pos)); return e ? e.id : null; };
assert.strictEqual(pick('DJ McLean', '[DRUMS]', 'Drums'), 'd', 'on drums → drum photo');
assert.strictEqual(pick('DJ McLean', '[VOX 3] MIX 3', 'Vocalist'), 'v', 'on vocals → vocal photo');
assert.strictEqual(pick('DJ McLean', '[KEYS 2]', 'Keys 2'), 'e', 'on keys, no keys photo → everyday photo');
assert.strictEqual(pick('DJ McLean', 'STAGE MANAGER', 'Stage Manager'), 'e', 'no instrument → everyday photo');
assert.strictEqual(pick('Kevin Michel', '[KEYS 2]', 'Keys 2'), 'k', 'Piano and Keys are one family');
assert.strictEqual(pick('Kevin Michel', '[DRUMS]', 'Drums'), 'k', 'only photo is on piano → still his own photo, not PCO');
assert.strictEqual(pick('Mike Powell', '[LEADER, VOX 1, ACOUSTIC] MIX 1', '[Leader]'), 'm', 'full name + instrument');
assert.strictEqual(pick('Craig Strok', '[DRUMS]', 'Drums'), null, 'nobody else\'s instrument photo is borrowed');
assert.deepStrictEqual([...photoParts('DJ Drums action').roles], ['drums'], 'extra words after the instrument are ignored');
assert.strictEqual(photoParts('Joshua P').roles.size, 0, 'a plain name has no instrument');
assert.strictEqual(photoParts('Keys').person, 'keys', 'the first word is always the person');
assert.deepStrictEqual([...spotFamilies('[BASS] MIX 6', 'Guitar [Bass]')], ['bass'], 'the spot decides, not the position');
assert.deepStrictEqual([...roleFamilies('Guitar [Rhythm]')], ['guitar'], 'rhythm guitar is guitar');
assert.strictEqual(photoKey('dj', new Set(['drums'])), 'dj|drums');
assert.strictEqual(photoKey('dj', new Set()), 'dj');
assert.strictEqual(photoKey('mp', spotFamilies('[LEADER, VOX 1, ACOUSTIC] MIX 1', '[Leader]')), 'mp|leader', 'leader spot files under leader');
console.log('photos by instrument: all assertions passed ✓');
}