'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) => `
Address

1 Example Road

Times
${inner}
Get Involved

Give now

`; const WOONSOCKET = page('

Sunday

9:00a & 11:00a

'); const NORTH_ATTLEBORO = page('

Sunday

7:30a
9:30a
11:30a

'); const ORLANDO = page('

Sundays

Welcome 10:00am

Start 10:30am

'); const SOUTHCOAST = page('

Sunday

10:30a

'); const APOLLO_BEACH = page( '

Sunday

' + '

9:00a & 11:00a

' + '
Office Hours

Mon-Wed 9:00a-4:30p

' ); 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
', () => { 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( '

Sunday

9:00a & 11:00a

' + '
Office Hours

Mon 9:00a

Thu 2:00p

' ); 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('

Redirected elsewhere

', { 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('
and block ends become line breaks', () => { assert.deepStrictEqual(htmlToLines('

7:30a
9:30a

'), ['7:30a', '9:30a']); assert.deepStrictEqual(htmlToLines('

9:00a & 11:00a

'), ['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 = `https://waterschurch.org/locations/ https://waterschurch.org/locations/north-attleboro/ https://waterschurch.org/locations/norwood/`; 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`);