Files
ridebus-admin/backend/test/telegramCancellationParser.test.js
Zakhar Timoshenko c0d40f9b90 feat: add support for multiple route numbers in alert notifications
- Updated FCM alert notifications documentation to include handling of multiple route numbers.
- Enhanced Telegram cancellation import logic to support multiple route numbers, ensuring correct notifications are sent for all directions.
- Modified frontend components to allow selection of multiple route numbers for alerts, improving user experience.
- Implemented backend migration to add `route_numbers` column to alerts and adjusted related database constraints.
- Introduced new utility functions for normalizing and validating route numbers in alert processing.
2026-10-05 12:07:09 +03:00

166 lines
10 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 { 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\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);
});
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'));
}
});