Files
ProdDash/modules/band-lineup/matching.test.js
T
christianandClaude Opus 5.5 e59629694e Band Lineup 1.13.0: your own photos win, a photo dropped on someone becomes theirs, and the spots stay in reach while dragging
Photos. Sync fell back to the Planning Center headshot whenever two
library photos matched a person equally ("Mike" and "mike"), and had no
way to know "Rose" is Rosaliz — so the booth dragged those in by hand
every week. Now:
- Several equally good library photos is not a reason to use PCO's; the
  first of them is used. PCO's headshot is only for someone with no photo.
- Dropping a library photo onto the synced person already in that spot
  (same name, same first name, or a nickname sharing its first three
  letters) makes it their photo: they keep their Planning Center name and
  spot, and it is remembered (settings.json photoFor) for every later
  Sync. Photos already dragged in by hand for this plan are learned the
  same way on the next Sync.
- A synced person's library photo is read live, so replacing that photo
  updates the board without a re-sync.

Dragging. The editor was one scrolling column, so a photo found far down
the list had to be carried back up to its spot, and every drop redrew the
editor back to the top. The spots are now pinned in their own pane above
the photos, each pane auto-scrolls when a drag nears its edge, both keep
their scroll position through redraws, and a search box filters the
photos by name.

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

119 lines
7.5 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 ✓');
}