- 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.
280 lines
18 KiB
JavaScript
280 lines
18 KiB
JavaScript
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 Мая');
|
||
});
|