Files
ProdDash/modules/overflow-tap/keywords.selftest.js
T
Waters ChurchandClaude Fable 5.1 6b51044be6 Overflow Tap 1.0.0: switch Overflow Tap destinations from keywords in ProPresenter slide notes
A new module that does what Overflow's "ProPresenter Integration" helper app
does, inside ProdDash: the live slide's notes are read from ProPresenter
(GET /v1/status/slide, twice a second) and when they carry one of the admin
page's keywords, the chosen Overflow Tap groups are pointed at that keyword's
destination. One tile: the keyword Tap is on (and since when), its
destination and the groups moved, a chip saying whether the slide on screen
carries a keyword, and the last four switches. A failed switch turns the tile
red with the reason, retries three times, and can flash an alert channel.

Admin config: ProPresenter endpoint + password; Overflow client ID + API key
(x-client-id / x-api-key, the public API's two headers); Tap groups to switch
(multiselect read from the account over /groups); keywords as text, one per
line (`give = Give Online`, optionally `@ Lobby, Kids Wing`); match mode
(whole word / anywhere / own line); alerts switch + channel.

The one unverified piece: Overflow's public API documents only reads for Tap
(OpenAPI checked 2026-10-07), while Overflow's helper uses the Dashboard's
private API. The switch is sent as PATCH /api/v3/tap/groups/{id} with the new
destinationId — the natural shape for this API — and every switch is confirmed
by reading the group back, so a refusal shows on the tile instead of passing
silently. overflow.js is the one place to change if Overflow names a
different route.

Also: tools/overflow-tap-mock.js (a ProPresenter whose slide notes carry
keywords on :1602 plus the Overflow Tap API on :24720, --read-only to fail
every switch), keywords.selftest.js (14 cases), README + CLAUDE.md entries.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 14:00:37 -04:00

116 lines
4.3 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use strict';
/**
* Self-test for keywords.js — the parsing and matching rules, pinned.
*
* node modules/overflow-tap/keywords.selftest.js
*/
const assert = require('node:assert/strict');
const { parseKeywordLines, keywordMatches, matchKeyword, cleanNotes } = require('./keywords');
let n = 0;
function test(name, fn) {
n += 1;
try {
fn();
console.log(`ok ${name}`);
} catch (err) {
console.log(`FAIL ${name}\n ${err.message}`);
process.exitCode = 1;
}
}
test('parses keyword = destination, with -> and → too', () => {
const m = parseKeywordLines('give = Give Online\nconnect -> Connect Card\nprayer → Prayer Request');
assert.deepEqual(m.map((x) => [x.keyword, x.destination, x.problem]), [
['give', 'Give Online', ''],
['connect', 'Connect Card', ''],
['prayer', 'Prayer Request', ''],
]);
});
test('a trailing @ names the groups for that line', () => {
const [m] = parseKeywordLines('connect = Connect Card @ Lobby, Kids Wing');
assert.equal(m.destination, 'Connect Card');
assert.deepEqual(m.groups, ['Lobby', 'Kids Wing']);
});
test('blank lines and # comments are skipped, line numbers kept', () => {
const m = parseKeywordLines('\n# giving\n\ngive = Give Online\n');
assert.equal(m.length, 1);
assert.equal(m[0].line, 4);
});
test('a line without a separator is a problem, not a crash', () => {
const [m] = parseKeywordLines('give');
assert.equal(m.keyword, 'give');
assert.match(m.problem, /No destination/);
});
test('missing keyword or destination is reported', () => {
const [a, b] = parseKeywordLines('= Give Online\ngive =');
assert.match(a.problem, /No keyword/);
assert.match(b.problem, /No destination/);
});
test('a repeated keyword is flagged and the first line wins', () => {
const m = parseKeywordLines('give = A\nGIVE = B');
assert.equal(m[0].problem, '');
assert.match(m[1].problem, /Repeated/);
assert.equal(matchKeyword('give', m).destination, 'A');
});
test('keywords and destinations may be phrases', () => {
const [m] = parseKeywordLines('next steps = Next Steps Card');
assert.equal(m.keyword, 'next steps');
assert.equal(keywordMatches('Please fill out your Next Steps card', 'next steps', 'word'), true);
});
test('whole word: give does not fire on forgiveness, does on "give!"', () => {
assert.equal(keywordMatches('a sermon on forgiveness', 'give', 'word'), false);
assert.equal(keywordMatches('give!', 'give', 'word'), true);
assert.equal(keywordMatches('Give', 'give', 'word'), true);
assert.equal(keywordMatches('#give now', 'give', 'word'), true);
assert.equal(keywordMatches('giveaway', 'give', 'word'), false);
assert.equal(keywordMatches('give_now', 'give', 'word'), false);
});
test('anywhere: plain substring, like the Overflow helper', () => {
assert.equal(keywordMatches('a sermon on forgiveness', 'give', 'anywhere'), true);
assert.equal(keywordMatches('nothing here', 'give', 'anywhere'), false);
});
test('line: the keyword on its own line only', () => {
assert.equal(keywordMatches('speaker note\ngive\nmore', 'give', 'line'), true);
assert.equal(keywordMatches('give now', 'give', 'line'), false);
assert.equal(keywordMatches(' Give ', 'give', 'line'), true);
});
test('regex characters in a keyword are literal', () => {
assert.equal(keywordMatches('go to (give) now', '(give)', 'word'), true);
assert.equal(keywordMatches('give.now', 'give.now', 'anywhere'), true);
assert.equal(keywordMatches('giveXnow', 'give.now', 'anywhere'), false);
});
test('empty notes never match; zero-width padding is ignored', () => {
assert.equal(keywordMatches('', 'give'), false);
assert.equal(keywordMatches('​', 'give', 'anywhere'), false);
assert.equal(cleanNotes('​give'), 'give');
assert.equal(keywordMatches('​give', 'give', 'line'), true);
});
test('matchKeyword returns the first mapping in admin order', () => {
const m = parseKeywordLines('connect = Connect Card\ngive = Give Online');
assert.equal(matchKeyword('give and connect', m).keyword, 'connect');
assert.equal(matchKeyword('just worship', m), null);
assert.equal(matchKeyword('give', []), null);
});
test('a mapping with a parse problem can still be the match (the caller decides)', () => {
const m = parseKeywordLines('baptism');
assert.equal(matchKeyword('baptism', m).problem.length > 0, true);
});
console.log(`${n} tests, exit ${process.exitCode || 0}`);