Files
ridebus-admin/backend/test/telegramCancellationParser.test.js

146 lines
9.0 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('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);
});