const test = require('node:test'); const assert = require('node:assert/strict'); const fs = require('node:fs'); const path = require('node:path'); const { parseCancellation, resolveCancellationStops, matchCancellationRoutes, buildCancellationAlert, normalizeStopKey, dayBounds } = require('../src/services/telegramCancellationParser'); const hospitalText = fs.readFileSync(path.join(__dirname, 'fixtures/telegram-hospital-cancellation.txt'), 'utf8'); const text = `Уважаемые пассажиры! Не будут выполняться рейсы:  ПО МАРШРУТУ № 27 С ОСТАНОВКИ АВ/ВОКЗАЛ НА 15.43,16.43.                 С ОСТАНОВКИ ЗОДЧЕГО НА 15.58,16.58. С УВАЖЕНИЕМ Филиал "Автобусный парк №2 г.Полоцка" ОАО "Витебскоблавтотранс"`; const parse = (value = text, publishedAt = '2026-10-02T12:52:00Z') => parseCancellation({ text: value, publishedAt }); test('carrier post preserves four individual departures and canonicalizes АВ/ВОКЗАЛ', () => { const result = parse(); assert.equal(result.status, 'ready'); assert.equal(result.serviceDate, '2026-10-02'); assert.deepEqual(result.groups, [{ routeNumber: '27', departures: [ { stop: 'Автовокзал', times: ['15:43', '16:43'] }, { stop: 'ЗОДЧЕГО', times: ['15:58', '16:58'] } ] }]); }); test('implicit date comes from publication in Minsk, never from import day or edit date', () => { assert.equal(parse(text, '2026-10-02T22:30:00Z').serviceDate, '2026-10-03'); }); test('tomorrow, numeric and Russian dates take precedence over publication day', () => { for (const prefix of ['Завтра\n', '03.10.2026\n', 'На 03.10\n', '3 октября 2026 года\n']) { assert.equal(parse(prefix + text).serviceDate, '2026-10-03', prefix); } assert.equal(parse('Завтра\n' + text, '2026-12-31T17:00:00Z').serviceDate, '2027-01-01'); assert.equal(parse('На 01.01\n' + text, '2026-12-31T17:00:00Z').serviceDate, '2027-01-01'); }); test('09.10 in a departure list remains a time', () => { assert.deepEqual(parse(text.replace('15.43', '09.10')).groups[0].departures[0].times, ['09:10', '16:43']); }); test('hospital post with ОТ ОСТАНОВКИ preserves all four times and uses the publication date', () => { const result = parse(hospitalText, '2026-10-05T04:16:53Z'); assert.equal(result.status, 'ready'); assert.equal(result.serviceDate, '2026-10-05'); assert.equal(result.inferredDate, true); assert.deepEqual(result.groups, [{ routeNumber: '2', departures: [ { stop: 'больница ксты', times: ['07:15', '08:45'] }, { stop: 'больница(новка)', times: ['08:00', '09:30'] } ] }]); assert.equal(dayBounds(result.serviceDate).end, '2026-10-05T21:00:00.000Z'); }); test('с/от wording may be mixed; real dates and invalid times remain validated', () => { for (const prefix of ['На 06.10\n', '06.10.2026\n', 'Завтра\n']) { const result = parse(prefix + hospitalText.replace('ОТ ОСТАНОВКИ', 'с остановки'), '2026-10-05T04:16:53Z'); assert.equal(result.status, 'ready', prefix); assert.equal(result.serviceDate, '2026-10-06', prefix); } const invalidDate = parse('На 31.02.2026\n' + hospitalText, '2026-10-05T04:16:53Z'); assert.deepEqual(invalidDate.reasons, ['Некорректная дата отмены.']); const invalidTime = parse(hospitalText.replace('7.15', '25.15'), '2026-10-05T04:16:53Z'); assert.deepEqual(invalidTime.reasons, ['Некорректное время отправления.']); assert.deepEqual(parse(text.replace('15.43', '09.10').replace(/С ОСТАНОВКИ/g, 'ОТ ОСТАНОВКИ')).groups[0].departures[0].times, ['09:10', '16:43']); }); test('ambiguous dates, conditions, invalid times and unspecified departures require review', () => { for (const value of [ 'Сегодня и завтра\n' + text, 'На 31.02.2026\n' + text, 'С 02.10 по 05.10\n' + text, 'По пятницам\n' + text, text.replace('15.43', '25.43'), text.replace('15.43', '15.99'), 'Не будут выполняться рейсы по маршруту №27.', text + '\nКроме отправления на 18:00.', 'Послезавтра\n' + text ]) assert.equal(parse(value).status, 'review', value); }); test('news is ignored; a picture without text stays on review', () => { assert.equal(parse('С праздником, уважаемые пассажиры!').status, 'ignored'); assert.equal(parseCancellation({ text: '', hasMedia: true, publishedAt: '2026-10-02T10:00:00Z' }).status, 'review'); }); test('review retains a recognized future date; attached media cannot be silently auto-published', () => { const result = parse('Завтра\nНе будут выполняться рейсы по маршруту №27.'); assert.equal(result.status, 'review'); assert.equal(result.serviceDate, '2026-10-03'); assert.equal(parseCancellation({ text, hasMedia: true, publishedAt: '2026-10-02T12:00:00Z' }).status, 'review'); }); test('different route numbers in one post are separate groups', () => { const result = parse(text + '\nПО МАРШРУТУ № 5А С ОСТАНОВКИ ЦЕНТР НА 18:00.'); assert.equal(result.status, 'ready'); assert.deepEqual(result.groups.map(g => g.routeNumber), ['27', '5а']); }); test('route number with two directions is matched by origin stop, with no guess on missing origin', () => { const routes = [ { id: 1, route_number: '27', origin_stop: 'Автовокзал' }, { id: 2, route_number: '27', origin_stop: 'Зодчего' }, { id: 3, route_number: '5', origin_stop: 'Центр' } ]; const matched = matchCancellationRoutes(parse(), routes); assert.deepEqual(matched.plans.map(p => p.routeId), [1, 2]); assert.equal(matched.reasons.length, 0); assert.equal(matchCancellationRoutes(parse(), routes.map(r => ({ ...r, origin_stop: '' }))).plans.length, 0); assert.equal(matchCancellationRoutes(parse(), []).reasons.length, 1); }); test('midnight expiry is an explicit UTC instant, including year boundary', () => { assert.deepEqual(dayBounds('2026-10-02'), { start: '2026-10-01T21:00:00.000Z', end: '2026-10-02T21:00:00.000Z' }); assert.equal(dayBounds('2026-12-31').end, '2026-12-31T21:00:00.000Z'); const plan = matchCancellationRoutes(parse(), [{ id: 1, route_number: '27' }]).plans[0]; const alert = buildCancellationAlert(plan, '2026-10-02', 'https://t.me/avp2Polotsk/1166', '2026-10-02T12:52:00Z', 1); assert.equal(alert.end_time, '2026-10-02T21:00:00.000Z'); assert.match(alert.message, /Автовокзал.*15:43, 16:43/); assert.match(alert.title, /отдельных рейсов/); }); module.exports = { carrierText: text }; test('dictionary selects the actual stop ID and uses its canonical name in concise alerts', () => { const stops = [{ id: 11, name: 'Автовокзал' }, { id: 22, name: 'Зодчего Иоанна' }, { id: 23, name: 'Зодчего Иоанна' }]; const aliases = [{ normalized_alias: normalizeStopKey('ЗОДЧЕГО'), stop_id: 22 }]; const parsed = resolveCancellationStops(parse(), stops, aliases); const routes = [ { id: 1, route_number: '27', origin_stop_id: 11, origin_stop: 'Автовокзал' }, { id: 2, route_number: '27', origin_stop_id: 22, origin_stop: 'Зодчего Иоанна' }, { id: 3, route_number: '27', origin_stop_id: 23, origin_stop: 'Зодчего Иоанна' } ]; const matched = matchCancellationRoutes(parsed, routes); assert.deepEqual(matched.plans.map(plan => plan.routeId), [1, 2]); const alert = buildCancellationAlert(matched.plans[1], '2026-10-02', 'https://t.me/avp2Polotsk/1166', '2026-10-02T12:52Z', 1); assert.equal(alert.message, '02.10.2026: не выполняются рейсы:\nЗодчего Иоанна — 15:58, 16:58\nИсточник: https://t.me/avp2Polotsk/1166'); assert.doesNotMatch(alert.message, /Уважаемые|С УВАЖЕНИЕМ/); assert.equal(normalizeStopKey(' « ЗОД-ЧЕГО » '), normalizeStopKey('Зодчего')); }); test('canonical names are taken from the catalog; inactive aliases and ambiguous platforms require review', () => { const parsed = parse(text.replace('ЗОДЧЕГО', 'ЗОДЧЕГО ИОАННА')); const stops = [{ id: 11, name: 'Автовокзал' }, { id: 22, name: 'Зодчего Иоанна' }]; assert.equal(resolveCancellationStops(parsed, stops, []).groups[0].departures[1].stop, 'Зодчего Иоанна'); const inactive = resolveCancellationStops(parse(), [stops[0]], [{ normalized_alias: 'зодчего', stop_id: 22 }]); assert.match(matchCancellationRoutes(inactive, [{ id: 1, route_number: '27' }]).reasons[0], /отключена/); const ambiguous = resolveCancellationStops(parsed, [...stops, { id: 23, name: 'Зодчего Иоанна' }], []); const result = matchCancellationRoutes(ambiguous, [{ id: 1, route_number: '27', origin_stop: 'Зодчего Иоанна' }, { id: 2, route_number: '27', origin_stop: 'Зодчего Иоанна' }]); assert.equal(result.plans.length, 0); });