314 lines
12 KiB
JavaScript
314 lines
12 KiB
JavaScript
'use strict';
|
||
|
||
/**
|
||
* Unit tests for the Service Times parser.
|
||
*
|
||
* node modules/service-times/parse.test.js
|
||
*
|
||
* Plain Node, no dependencies; exits non-zero on the first failure. The
|
||
* fixtures are trimmed from the real campus pages, so each one stands for a
|
||
* shape the website actually publishes — including Apollo Beach's "Office
|
||
* Hours" block, which sits inside the Times section and must never be read
|
||
* as a service.
|
||
*/
|
||
|
||
const assert = require('assert');
|
||
const {
|
||
htmlToLines,
|
||
slugify,
|
||
dayFromLine,
|
||
timesFromLine,
|
||
timesSection,
|
||
parseCampusPage,
|
||
discoverCampusSlugs,
|
||
parseManualCampuses,
|
||
mergeCampuses,
|
||
parseFeeds,
|
||
assignRoles,
|
||
} = require('./parse');
|
||
|
||
let passed = 0;
|
||
function test(name, fn) {
|
||
try {
|
||
fn();
|
||
passed += 1;
|
||
console.log(` ok ${name}`);
|
||
} catch (err) {
|
||
console.error(`FAIL ${name}\n ${err.message}`);
|
||
process.exit(1);
|
||
}
|
||
}
|
||
|
||
/** The page-builder wrapper every campus's Times block sits in. */
|
||
const page = (inner) => `
|
||
<div class="wpb_wrapper"><h5 class="q_title">Address</h5><p>1 Example Road</p></div>
|
||
<div class="wpb_wrapper"><h5 class="q_title">Times</h5>
|
||
${inner}
|
||
</div>
|
||
<div class="wpb_wrapper"><h5 class="q_title">Get Involved</h5><p>Give now</p></div>
|
||
<div class="footer"><p>Mon – Thu 9a – 4:30p</p></div>
|
||
`;
|
||
|
||
const WOONSOCKET = page('<p><strong>Sunday</strong></p><p>9:00a & 11:00a</p>');
|
||
const NORTH_ATTLEBORO = page('<p><strong>Sunday</strong></p><p>7:30a<br />9:30a<br />11:30a</p>');
|
||
const ORLANDO = page('<p><b>Sundays</b></p><p>Welcome 10:00am</p><p>Start 10:30am</p>');
|
||
const SOUTHCOAST = page('<p><strong>Sunday</strong></p><p>10:30a</p>');
|
||
const APOLLO_BEACH = page(
|
||
'<p><strong style="color:#333">Sunday</strong></p>'
|
||
+ '<p><span style="color:#333">9:00a & 11:00a</span></p>'
|
||
+ '<h5 class="q_title">Office Hours</h5><p>Mon-Wed 9:00a-4:30p</p>'
|
||
);
|
||
|
||
const times = (html, slug) => parseCampusPage(html, { slug }).services.map((s) => s.text);
|
||
|
||
console.log('campus pages');
|
||
|
||
test('Woonsocket: two times joined by "&" on one line', () => {
|
||
assert.deepStrictEqual(times(WOONSOCKET, 'woonsocket'), ['9:00a', '11:00a']);
|
||
});
|
||
|
||
test('North Attleboro: three times separated by <br>', () => {
|
||
assert.deepStrictEqual(times(NORTH_ATTLEBORO, 'north-attleboro'), ['7:30a', '9:30a', '11:30a']);
|
||
});
|
||
|
||
test('Southcoast: a single service', () => {
|
||
assert.deepStrictEqual(times(SOUTHCOAST, 'southcoast'), ['10:30a']);
|
||
});
|
||
|
||
test('Orlando: "am" spelling, one labelled time per line', () => {
|
||
const services = parseCampusPage(ORLANDO, { slug: 'orlando' }).services;
|
||
assert.deepStrictEqual(services.map((s) => s.text), ['10:00a', '10:30a']);
|
||
assert.deepStrictEqual(services.map((s) => s.label), ['Welcome', 'Start']);
|
||
});
|
||
|
||
test('Apollo Beach: the Office Hours block inside Times is not a service', () => {
|
||
assert.deepStrictEqual(times(APOLLO_BEACH, 'apollo-beach'), ['9:00a', '11:00a']);
|
||
});
|
||
|
||
/* Apollo Beach's real page lists an hours *range* under Office Hours, which the
|
||
range guard rejects on its own — so the test above passes even if the
|
||
stop-heading breaks. This fixture puts plain times there instead, where only
|
||
the stop-heading can keep them out: without it, the 2:00p becomes a service. */
|
||
const OFFICE_HOURS_PLAIN = page(
|
||
'<p><strong>Sunday</strong></p><p>9:00a & 11:00a</p>'
|
||
+ '<h5 class="q_title">Office Hours</h5><p>Mon 9:00a</p><p>Thu 2:00p</p>'
|
||
);
|
||
|
||
test('an Office Hours block ends the service list, range or not', () => {
|
||
assert.deepStrictEqual(times(OFFICE_HOURS_PLAIN, 'apollo-beach'), ['9:00a', '11:00a']);
|
||
});
|
||
|
||
|
||
test('the footer\'s main-office hours never reach the services', () => {
|
||
for (const [html, slug] of [[WOONSOCKET, 'woonsocket'], [SOUTHCOAST, 'southcoast']]) {
|
||
assert.ok(!times(html, slug).includes('4:30p'), `${slug} picked up the footer`);
|
||
}
|
||
});
|
||
|
||
test('names and days come out as the site writes them', () => {
|
||
const campus = parseCampusPage(NORTH_ATTLEBORO, { slug: 'north-attleboro' });
|
||
assert.strictEqual(campus.name, 'North Attleboro');
|
||
assert.deepStrictEqual([...new Set(campus.services.map((s) => s.day))], ['Sunday']);
|
||
});
|
||
|
||
test('ids are stable and unique — marks are keyed on them', () => {
|
||
const first = parseCampusPage(ORLANDO, { slug: 'orlando' }).services.map((s) => s.id);
|
||
const again = parseCampusPage(ORLANDO, { slug: 'orlando' }).services.map((s) => s.id);
|
||
assert.deepStrictEqual(first, ['1000-welcome', '1030-start']);
|
||
assert.deepStrictEqual(first, again);
|
||
assert.strictEqual(new Set(first).size, first.length);
|
||
});
|
||
|
||
test('a page that no longer has a Times section reports it', () => {
|
||
const campus = parseCampusPage('<h1>Redirected elsewhere</h1>', { slug: 'norwood' });
|
||
assert.deepStrictEqual(campus.services, []);
|
||
assert.match(campus.error, /Times/);
|
||
});
|
||
|
||
console.log('pieces');
|
||
|
||
test('24-hour values and sort order', () => {
|
||
const [nine] = timesFromLine('9:00a');
|
||
const [noon] = timesFromLine('12:00p');
|
||
const [midnight] = timesFromLine('12:00a');
|
||
const [evening] = timesFromLine('6:30p');
|
||
assert.strictEqual(nine.hour24, 9);
|
||
assert.strictEqual(noon.hour24, 12);
|
||
assert.strictEqual(midnight.hour24, 0);
|
||
assert.strictEqual(evening.minutes, 18 * 60 + 30);
|
||
});
|
||
|
||
test('an hours range is not a service list', () => {
|
||
assert.deepStrictEqual(timesFromLine('Mon-Wed 9:00a-4:30p'), []);
|
||
assert.deepStrictEqual(timesFromLine('9a – 4:30p'), []);
|
||
});
|
||
|
||
test('prose that merely contains a number is not a time', () => {
|
||
assert.deepStrictEqual(timesFromLine('1 pastor, 2 people'), []);
|
||
assert.deepStrictEqual(timesFromLine('Apollo Beach campus'), []);
|
||
});
|
||
|
||
test('weekday headings, singular or plural, in any case', () => {
|
||
assert.strictEqual(dayFromLine('Sunday'), 'Sunday');
|
||
assert.strictEqual(dayFromLine('SUNDAYS'), 'Sunday');
|
||
assert.strictEqual(dayFromLine(' Saturday '), 'Saturday');
|
||
assert.strictEqual(dayFromLine('Sunday 9:00a'), '');
|
||
assert.strictEqual(dayFromLine('Get Involved'), '');
|
||
});
|
||
|
||
test('<br> and block ends become line breaks', () => {
|
||
assert.deepStrictEqual(htmlToLines('<p>7:30a<br />9:30a</p>'), ['7:30a', '9:30a']);
|
||
assert.deepStrictEqual(htmlToLines('<p>9:00a & 11:00a</p>'), ['9:00a & 11:00a']);
|
||
});
|
||
|
||
test('the Times slice stops at Get Involved', () => {
|
||
const section = timesSection(WOONSOCKET);
|
||
assert.ok(section.includes('9:00a'));
|
||
assert.ok(!section.includes('Give now'));
|
||
});
|
||
|
||
test('campus slugs are discovered from a sitemap', () => {
|
||
const xml = `<url><loc>https://waterschurch.org/locations/</loc></url>
|
||
<url><loc>https://waterschurch.org/locations/north-attleboro/</loc></url>
|
||
<url><loc>https://waterschurch.org/locations/norwood/</loc></url>`;
|
||
assert.deepStrictEqual(discoverCampusSlugs(xml), ['north-attleboro', 'norwood']);
|
||
});
|
||
|
||
test('slugs survive punctuation and case', () => {
|
||
assert.strictEqual(slugify('North Attleboro'), 'north-attleboro');
|
||
assert.strictEqual(slugify(' Apollo Beach! '), 'apollo-beach');
|
||
});
|
||
|
||
console.log('manual campuses');
|
||
|
||
test('name, day and times — day optional', () => {
|
||
const { campuses, errors } = parseManualCampuses(
|
||
'Plymouth | Sunday | 9:00a, 11:00a\nQuincy | 10:30a\n'
|
||
);
|
||
assert.deepStrictEqual(errors, []);
|
||
assert.deepStrictEqual(campuses.map((c) => c.name), ['Plymouth', 'Quincy']);
|
||
assert.deepStrictEqual(campuses[0].services.map((s) => s.text), ['9:00a', '11:00a']);
|
||
assert.deepStrictEqual(campuses[1].services.map((s) => s.day), ['Sunday']);
|
||
assert.ok(campuses[0].manual);
|
||
});
|
||
|
||
test('blank lines and # comments are skipped; a line with no time is reported', () => {
|
||
const { campuses, errors } = parseManualCampuses('\n# not yet on the site\nPlymouth | soon\n');
|
||
assert.deepStrictEqual(campuses, []);
|
||
assert.strictEqual(errors.length, 1);
|
||
assert.match(errors[0], /Line 3/);
|
||
});
|
||
|
||
console.log('merging');
|
||
|
||
test('the website wins once it publishes a campus the operator added by hand', () => {
|
||
const site = [{ slug: 'plymouth', name: 'Plymouth', services: [{ id: '0900', text: '9:00a' }], error: '' }];
|
||
const manual = parseManualCampuses('Plymouth | 10:30a').campuses;
|
||
const merged = mergeCampuses(site, manual);
|
||
assert.strictEqual(merged.length, 1);
|
||
assert.deepStrictEqual(merged[0].services.map((s) => s.text), ['9:00a']);
|
||
assert.strictEqual(merged[0].manual, false);
|
||
assert.ok(merged[0].supersededManual, 'the admin page should be able to say the line can go');
|
||
});
|
||
|
||
test('a manual campus the site does not have yet is kept', () => {
|
||
const merged = mergeCampuses([], parseManualCampuses('Plymouth | 10:30a').campuses);
|
||
assert.deepStrictEqual(merged.map((c) => c.name), ['Plymouth']);
|
||
assert.ok(merged[0].manual);
|
||
});
|
||
|
||
test('a site page that parses to nothing falls back to the manual line', () => {
|
||
const site = [{ slug: 'plymouth', name: 'Plymouth', services: [], error: 'No "Times" section on the page' }];
|
||
const merged = mergeCampuses(site, parseManualCampuses('Plymouth | 10:30a').campuses);
|
||
assert.deepStrictEqual(merged[0].services.map((s) => s.text), ['10:30a']);
|
||
assert.strictEqual(merged[0].error, '');
|
||
assert.ok(merged[0].manual);
|
||
});
|
||
|
||
console.log('resi sources');
|
||
|
||
test('campus = broadcast campus, by name or slug, with -> and : accepted', () => {
|
||
const { feeds, errors } = parseFeeds(
|
||
'Woonsocket = North Attleboro\nsouthcoast -> north-attleboro\n# a comment\n\nOrlando: Apollo Beach'
|
||
);
|
||
assert.deepStrictEqual(errors, []);
|
||
assert.strictEqual(feeds.get('woonsocket'), 'north-attleboro');
|
||
assert.strictEqual(feeds.get('southcoast'), 'north-attleboro');
|
||
assert.strictEqual(feeds.get('orlando'), 'apollo-beach');
|
||
});
|
||
|
||
test('a line that names no source is reported, not dropped in silence', () => {
|
||
const { feeds, errors } = parseFeeds('woonsocket\nsouthcoast = north-attleboro');
|
||
assert.strictEqual(feeds.size, 1);
|
||
assert.strictEqual(errors.length, 1);
|
||
assert.match(errors[0], /Line 1/);
|
||
});
|
||
|
||
console.log('roles and dependents');
|
||
|
||
/** The four campuses production runs, set up the way the booth runs them. */
|
||
const BOARD = [
|
||
{ slug: 'north-attleboro', name: 'North Attleboro', services: [{ id: '0730' }, { id: '0930' }, { id: '1130' }] },
|
||
{ slug: 'woonsocket', name: 'Woonsocket', services: [{ id: '0900' }, { id: '1100' }] },
|
||
{ slug: 'southcoast', name: 'Southcoast', services: [{ id: '1030' }] },
|
||
{ slug: 'apollo-beach', name: 'Apollo Beach', services: [{ id: '0900' }, { id: '1100' }] },
|
||
];
|
||
const SETUP = {
|
||
broadcast: ['north-attleboro', 'apollo-beach'],
|
||
feeds: parseFeeds('woonsocket = north-attleboro\nsouthcoast = north-attleboro').feeds,
|
||
};
|
||
const byName = (campuses, name) => campuses.find((c) => c.name === name);
|
||
|
||
test('the broadcast campuses originate and take resi from nobody', () => {
|
||
const roles = assignRoles(BOARD, SETUP);
|
||
for (const name of ['North Attleboro', 'Apollo Beach']) {
|
||
assert.strictEqual(byName(roles, name).role, 'broadcast', name);
|
||
assert.strictEqual(byName(roles, name).source, '', `${name} should have no source`);
|
||
}
|
||
});
|
||
|
||
test('every other campus takes resi from the campus it is mapped to', () => {
|
||
const roles = assignRoles(BOARD, SETUP);
|
||
for (const name of ['Woonsocket', 'Southcoast']) {
|
||
const campus = byName(roles, name);
|
||
assert.strictEqual(campus.role, 'resi', name);
|
||
assert.strictEqual(campus.source, 'north-attleboro', name);
|
||
assert.strictEqual(campus.sourceName, 'North Attleboro', name);
|
||
assert.strictEqual(campus.sourceMissing, false, name);
|
||
}
|
||
});
|
||
|
||
test('an origin lists who is riding on it — the answer the board exists for', () => {
|
||
const roles = assignRoles(BOARD, SETUP);
|
||
assert.deepStrictEqual(
|
||
byName(roles, 'North Attleboro').dependents.map((d) => d.name),
|
||
['Woonsocket', 'Southcoast']
|
||
);
|
||
assert.deepStrictEqual(byName(roles, 'Apollo Beach').dependents, []);
|
||
});
|
||
|
||
test('a source that is not on the board is flagged, not read as unassigned', () => {
|
||
const roles = assignRoles(BOARD, {
|
||
broadcast: ['north-attleboro'],
|
||
feeds: parseFeeds('woonsocket = attleboro-north').feeds, // a typo
|
||
});
|
||
const woonsocket = byName(roles, 'Woonsocket');
|
||
assert.strictEqual(woonsocket.source, 'attleboro-north');
|
||
assert.strictEqual(woonsocket.sourceMissing, true);
|
||
assert.deepStrictEqual(byName(roles, 'North Attleboro').dependents, []);
|
||
});
|
||
|
||
test('a campus with no mapping reads as having no source at all', () => {
|
||
const roles = assignRoles(BOARD, { broadcast: ['north-attleboro'], feeds: new Map() });
|
||
assert.strictEqual(byName(roles, 'Southcoast').source, '');
|
||
assert.strictEqual(byName(roles, 'Southcoast').sourceMissing, false);
|
||
});
|
||
|
||
test('assignRoles leaves the campuses it was handed alone', () => {
|
||
const input = [{ slug: 'woonsocket', name: 'Woonsocket', services: [] }];
|
||
assignRoles(input, SETUP);
|
||
assert.strictEqual(input[0].role, undefined, 'the caller’s objects must not be rewritten');
|
||
});
|
||
|
||
console.log(`\n${passed} tests passed`);
|