Files
ridebus-admin/backend/test/telegramCancellationParser.test.js
Zakhar Timoshenko 249c338c2a feat: enhance Telegram import functionality to support delays and cancellations
- Updated telegramCancellationParser to include parsing for delay notices.
- Added new test cases for handling delay messages and stop-point cancellations.
- Modified telegramImport integration tests to validate new delay handling.
- Enhanced documentation to reflect the addition of delay notifications.
- Updated TelegramImportPanel component to accommodate new alert types.
- Introduced new fixture files for testing delay and cancellation messages.
2026-10-09 02:59:52 +03:00

280 lines
18 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.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const { parseTransitNotice, buildTransitNoticeAlert, 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('one route number targets all its directions without requiring an origin match', () => {
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[0].routeIds, [1, 2]);
assert.equal(matched.reasons.length, 0);
assert.equal(matchCancellationRoutes(parse(), routes.map(r => ({ ...r, origin_stop: '' }))).plans.length, 1);
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[0].routeIds, [1, 2, 3]);
const alert = buildCancellationAlert(matched.plans[0], '2026-10-02', 'https://t.me/avp2Polotsk/1166', '2026-10-02T12:52Z', 1);
assert.equal(alert.message, '02.10.2026: не выполняются рейсы:\nАвтовокзал — 15:43, 16:43\nЗодчего Иоанна — 15:58, 16:58');
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);
});
test('several numbered sections produce one concise alert without losing departures', () => {
const parsed = parse(text + '\nПО МАРШРУТУ № 5А ОТ ОСТАНОВКИ ЦЕНТР НА 18:00.');
const matched = matchCancellationRoutes(parsed, [{ id: 1, route_number: '27' }, { id: 2, route_number: '27' }, { id: 3, route_number: '5А' }]);
const alert = buildCancellationAlert(matched.plans, parsed.serviceDate, 'https://t.me/avp2Polotsk/1', '2026-10-02T12:00Z', 1);
assert.deepEqual(alert.route_numbers, ['27', '5А']);
assert.equal(alert.route_id, null);
assert.equal(alert.title, 'Отмена отдельных рейсов №27, №5А');
assert.match(alert.message, /Маршрут №27:\nАвтовокзал — 15:43, 16:43\nЗОДЧЕГО — 15:58, 16:58\n\nМаршрут №5А:\nЦЕНТР — 18:00/);
assert.deepEqual(matchCancellationRoutes(parsed, [{ id: 1, route_number: '27' }]).plans, [], 'unknown number prevents partial automatic publication');
});
test('a shared explicit departure list may name several route numbers', () => {
for (const list of ['№2, №27', '№2 и №27', '№2; 27']) {
const parsed = parse(`Не будут выполняться рейсы по маршрутам ${list} с остановки ЦЕНТР на 18.00,19.00.`);
assert.equal(parsed.status, 'ready', list);
assert.deepEqual(parsed.groups.map(g => g.routeNumber), ['2', '27']);
assert.ok(parsed.groups.every(g => g.departures[0].times.join(',') === '18:00,19:00'));
}
});
const delayText = fs.readFileSync(path.join(__dirname, 'fixtures/telegram-delay-route-4.txt'), 'utf8');
const stopPointText = fs.readFileSync(path.join(__dirname, 'fixtures/telegram-stop-point-cancellation.txt'), 'utf8');
const route4Stops = [{ id: 41, name: 'Мариненко' }, { id: 42, name: 'Боровуха' }];
const route4 = [{ id: 4, route_number: '4' }, { id: 40, route_number: '4' }];
function route4Alert(parsed, publishedAt) {
const resolved = resolveCancellationStops(parsed, route4Stops, []);
const matched = matchCancellationRoutes(resolved, route4);
assert.deepEqual(matched.reasons, []);
return buildTransitNoticeAlert(matched.plans, parsed.serviceDate, 'https://t.me/avp2Polotsk/1700', publishedAt, 1, parsed);
}
test('screenshot delay is a delay notice with both departures and Minsk publication-day expiry', () => {
const publishedAt = '2026-10-08T16:14:01Z';
const parsed = parseTransitNotice({ text: delayText, publishedAt });
assert.equal(parsed.status, 'ready');
assert.equal(parsed.alertType, 'delay');
assert.equal(parsed.serviceDate, '2026-10-08');
assert.deepEqual(parsed.groups[0].departures.map(d => d.times), [['19:05'], ['20:00']]);
const alert = route4Alert(parsed, publishedAt);
assert.equal(alert.alert_type, 'delay');
assert.deepEqual(alert.route_numbers, ['4']);
assert.equal(alert.title, 'Задержка отдельных рейсов №4');
assert.equal(alert.message, '08.10.2026: с опозданием выполняются рейсы:\nМариненко — 19:05\nБоровуха — 20:00');
assert.equal(alert.start_time, '2026-10-08T16:14:01.000Z');
assert.equal(alert.end_time, '2026-10-08T21:00:00.000Z');
});
test('screenshot stop-point heading and quoted continuation preserve four cancelled departures', () => {
const publishedAt = '2026-10-08T02:03:13Z';
const parsed = parseTransitNotice({ text: stopPointText, publishedAt, editedAt: '2026-10-09T01:00Z' }, { preserveSourceStops: true });
assert.equal(parsed.status, 'ready');
assert.equal(parsed.alertType, 'cancellation');
assert.equal(parsed.serviceDate, '2026-10-08');
assert.deepEqual(parsed.groups, [{ routeNumber: '4', departures: [
{ stop: 'Мариненко', sourceStop: 'Мариненко', times: ['06:00', '07:30'] },
{ stop: 'Боровуха', sourceStop: 'Боровуха', times: ['06:40', '08:15'] }
] }]);
const alert = route4Alert(parsed, publishedAt);
assert.equal(alert.alert_type, 'cancellation');
assert.equal(alert.message, '08.10.2026: не выполняются рейсы:\nМариненко — 06:00, 07:30\nБоровуха — 06:40, 08:15');
assert.equal(alert.end_time, '2026-10-08T21:00:00.000Z');
});
test('stop-point heading supports quote variants, mixed prefixes and real dates independently from times', () => {
for (const [opening, closing] of [['"', '"'], ['«', '»'], ['“', '”']]) {
const text = stopPointText.replace(/"(Мариненко|Боровуха)"/g, (_, name) => opening + name + closing).replace('6.00', '09.10');
const parsed = parseTransitNotice({ text: 'На 09.10\n' + text, publishedAt: '2026-10-08T02:03:13Z' });
assert.equal(parsed.status, 'ready');
assert.equal(parsed.serviceDate, '2026-10-09');
assert.deepEqual(parsed.groups[0].departures[0].times, ['09:10', '07:30']);
}
const mixed = stopPointText.replace('"Боровуха"', 'ОТ ОСТАНОВКИ «Боровуха»');
assert.equal(parseTransitNotice({ text: mixed, publishedAt: '2026-10-08T02:03:13Z' }).status, 'ready');
for (const text of [stopPointText.replace('6.00', '25.00'), 'На 31.02.2026\n' + stopPointText,
stopPointText.replace('с остановочного пункта', ''), stopPointText + '\nДополнительно в 09:00']) {
assert.equal(parseTransitNotice({ text, publishedAt: '2026-10-08T02:03:13Z' }).status, 'review');
}
});
test('possible delay stays possible and unspecified duration is never invented or silently dropped', () => {
const publishedAt = '2026-10-08T16:14:01Z';
for (const wording of ['могут выполняться рейсы с опозданием', 'рейсы могут прибыть с опозданием', 'рейсы могут прийти с опозданием']) {
const parsed = parseTransitNotice({ text: delayText.replace('будут выполняться рейсы с опозданием', wording), publishedAt });
assert.equal(parsed.status, 'ready');
assert.equal(parsed.delayPossible, true);
const alert = route4Alert(parsed, publishedAt);
assert.match(alert.title, /Возможное опоздание/);
assert.match(alert.message, /возможны опоздания рейсов/);
assert.doesNotMatch(alert.message, /минут|час/);
}
for (const duration of [' на 20 минут', ' на 20 мин.', ' на 1 час']) {
const parsed = parseTransitNotice({ text: delayText.replace('с опозданием:', 'с опозданием' + duration + ':'), publishedAt });
assert.equal(parsed.status, 'review');
assert.equal(parsed.alertType, 'delay');
assert.match(parsed.reasons[0], /величина опоздания/);
}
});
test('mixed types, negated delay, conditions and incomplete departures stay on review; unrelated execution is ignored', () => {
const publishedAt = '2026-10-08T16:14:01Z';
for (const text of [delayText + '\n' + stopPointText,
delayText.replace('будут выполняться', 'не могут выполняться'),
delayText + '\nДвижение восстановлено.',
delayText.replace(' НА 20.00', ''),
delayText + '\nОстальные рейсы будут выполняться.']) {
assert.equal(parseTransitNotice({ text, publishedAt }).status, 'review', text);
}
assert.equal(parseTransitNotice({ text: 'Будут выполняться рейсы по маршруту №4.', publishedAt }).status, 'ignored');
assert.equal(parseTransitNotice({ text: 'С опозданием поздравляем с праздником!', publishedAt }).status, 'ignored');
const withMedia = parseTransitNotice({ text: delayText, publishedAt, hasMedia: true });
assert.equal(withMedia.status, 'review');
assert.equal(withMedia.alertType, 'delay');
});
test('multiple delayed routes are combined while every stop and departure remains visible', () => {
const publishedAt = '2026-10-08T16:14:01Z';
const parsed = parseTransitNotice({ text: delayText + '\nПО МАРШРУТУ №27 С ОСТАНОВКИ АВ/ВОКЗАЛ НА 20.15,21.00.', publishedAt });
const resolved = resolveCancellationStops(parsed, [...route4Stops, { id: 1, name: 'Автовокзал' }], []);
const matched = matchCancellationRoutes(resolved, [...route4, { id: 27, route_number: '27' }]);
const alert = buildTransitNoticeAlert(matched.plans, parsed.serviceDate, '', publishedAt, 1, parsed);
assert.equal(alert.alert_type, 'delay');
assert.deepEqual(alert.route_numbers, ['4', '27']);
assert.match(alert.message, /Маршрут №4:\nМариненко — 19:05\nБоровуха — 20:00/);
assert.match(alert.message, /Маршрут №27:\nАвтовокзал — 20:15, 21:00/);
});
test('calendar words in a stop name are not mistaken for a service date', () => {
const parsed = parseTransitNotice({ text: stopPointText.replace('Мариненко', '1 Мая'), publishedAt: '2026-10-08T02:03:13Z' });
assert.equal(parsed.status, 'ready');
assert.equal(parsed.serviceDate, '2026-10-08');
assert.equal(parsed.groups[0].departures[0].stop, '1 Мая');
});