202 lines
15 KiB
JavaScript
202 lines
15 KiB
JavaScript
const assert = require('node:assert/strict');
|
||
const test = require('node:test');
|
||
const fs = require('node:fs');
|
||
const path = require('node:path');
|
||
const { parseSchedule, parseCatalog, parseNews, safeUrl, fetchHtml } = require('../src/services/scheduleSource');
|
||
const { buildPlan, compareTrips, buildDirectionPlan, departureStops } = require('../src/services/scheduleImportService');
|
||
|
||
const fixture = name => fs.readFileSync(path.join(__dirname, 'fixtures/schedule-import', `${name}.html`), 'utf8');
|
||
const source = parseSchedule(fixture('route2'), 'https://ap2polotsk.of.by/ap2/m-2/');
|
||
const snapshot = { route: { id: 2, route_number: '2' }, schedules: [], stops: [
|
||
{ stop_id: 1, name: 'Больница (Ксты)' }, { stop_id: 2, name: 'Швейная фабрика' },
|
||
{ stop_id: 3, name: 'Больница (Новка)' }, { stop_id: 4, name: 'Автовокзал' },
|
||
] };
|
||
const mappings = { 'Больница (Ксты)': { mode: 'departure', startStopId: 1, endStopId: 3 },
|
||
'Больница (Новка)': { mode: 'departure', startStopId: 3, endStopId: 1 },
|
||
'Швейная фабрика': { mode: 'checkpoint' }, 'Автовокзал': { mode: 'checkpoint' } };
|
||
const plan = (data = source, map = mappings, state = snapshot, date = '2026-09-16') => buildPlan(data, map, state, date, '2026-09-16');
|
||
|
||
test('each admin direction imports only its departures and derives its endpoints from stop order', () => {
|
||
const outbound = { ...snapshot, stops: snapshot.stops.slice(0, 3) };
|
||
const inbound = { ...snapshot, route: { id: 5, route_number: '2' }, stops: [...outbound.stops].reverse() };
|
||
const forward = buildDirectionPlan(source, 'Больница (Ксты)', outbound, '2026-09-16', '2026-09-16');
|
||
const backward = buildDirectionPlan(source, 'Больница (Новка)', inbound, '2026-09-16', '2026-09-16');
|
||
assert.deepEqual(forward.changes.map(c => c.after.length), [17, 16, 16]);
|
||
assert.deepEqual(backward.changes.map(c => c.after.length), [16, 14, 14]);
|
||
assert.ok(forward.changes.every(c => c.after.every(t => t.startStopId === 1 && t.endStopId === 3)));
|
||
assert.ok(backward.changes.every(c => c.after.every(t => t.startStopId === 3 && t.endStopId === 1)));
|
||
assert.deepEqual(departureStops(source), ['Больница (Ксты)', 'Больница (Новка)']);
|
||
assert.throws(() => buildDirectionPlan(source, 'Швейная фабрика', outbound, '2026-09-16', '2026-09-16'), /строку отправлений/);
|
||
assert.throws(() => buildDirectionPlan(source, 'Больница (Ксты)', { ...outbound, stops: [] }, '2026-09-16', '2026-09-16'), /первая и последняя/);
|
||
});
|
||
|
||
test('real catalog finds 15 routes and distinguishes wrongly labelled 8A', () => {
|
||
const routes = parseCatalog(fixture('catalog'));
|
||
assert.equal(routes.length, 15);
|
||
assert.equal(routes.find(r => r.url.endsWith('/m-8a/')).number, '8А');
|
||
assert.equal(routes.at(-1).number, '28Э');
|
||
});
|
||
|
||
test('real route tables retain directions, checkpoint rows and weekend categories', () => {
|
||
assert.equal(source.rows.length, 8);
|
||
assert.equal(source.directions.length, 2);
|
||
assert.deepEqual(source.warnings, []);
|
||
const changes = plan().changes;
|
||
assert.deepEqual(changes.map(c => [c.dayType, c.after.length]), [['weekday', 33], ['saturday', 30], ['sunday', 30]]);
|
||
assert.equal(changes[0].after.filter(t => t.startStopId === 1).length, 17);
|
||
assert.equal(changes[0].after.filter(t => t.startStopId === 3).length, 16);
|
||
});
|
||
|
||
test('every row needs a decision and route stop ids are validated', () => {
|
||
assert.throws(() => plan(source, {}), /назначение/);
|
||
assert.throws(() => plan(source, { ...mappings, 'Больница (Ксты)': { mode: 'departure', startStopId: 999, endStopId: 3 } }), /остановки/);
|
||
assert.throws(() => plan(source, Object.fromEntries(Object.keys(mappings).map(k => [k, { mode: 'checkpoint' }]))), /хотя бы одну/);
|
||
assert.throws(() => plan(source, mappings, { ...snapshot, route: { route_number: '3' } }), /не совпадает/);
|
||
});
|
||
|
||
test('marked, malformed and footnoted departures block the entire import', () => {
|
||
for (const replacement of ['06:15*', '25:15', '<sup>06:15</sup>', '<del>06:15</del>']) {
|
||
const marked = parseSchedule(fixture('route2').replace('06:15', replacement), source.url);
|
||
assert.ok(marked.warnings.length);
|
||
assert.throws(() => plan(marked), /нераспознанные/);
|
||
}
|
||
const footnote = parseSchedule(fixture('route2').replace('Остановочные пункты:', 'Примечание: только по пятницам. Остановочные пункты:'), source.url);
|
||
assert.ok(footnote.warnings.length);
|
||
});
|
||
|
||
test('unrecognized day sections cannot become partial silent imports', () => {
|
||
const changed = parseSchedule(fixture('route2').replace('Рабочий день', 'Только по пятницам'), source.url);
|
||
assert.ok(changed.warnings.length);
|
||
assert.throws(() => plan(changed), /нераспознанные/);
|
||
});
|
||
|
||
test('same trips produce no changes; restriction removal is visible in the diff', () => {
|
||
const schedules = plan().changes.map((c, i) => ({ id: i + 1, day_type: c.dayType, trips: c.after, valid_from: '2026-09-16' }));
|
||
assert.equal(plan(source, mappings, { ...snapshot, schedules }).hasChanges, false);
|
||
schedules[0].trips = schedules[0].trips.map((trip, i) => i === 0 ? { ...trip, exceptDaysOfWeek: [5] } : trip);
|
||
const diff = plan(source, mappings, { ...snapshot, schedules }).changes[0];
|
||
assert.equal(diff.added.length, 1);
|
||
assert.deepEqual(diff.removed[0].exceptDaysOfWeek, [5]);
|
||
assert.equal(compareTrips([diff.after[0], diff.after[0]], [diff.after[0]]).removed.length, 1);
|
||
});
|
||
|
||
test('future overlaps, invalid and historical dates are rejected', () => {
|
||
for (const date of ['2026-09-15', '2026-02-30', 'invalid']) assert.throws(() => plan(source, mappings, snapshot, date), /дату/);
|
||
const schedules = [{ id: 1, day_type: 'weekday', valid_from: '2026-09-20', trips: [] }];
|
||
assert.throws(() => plan(source, mappings, { ...snapshot, schedules }), /будущие/);
|
||
});
|
||
|
||
test('news keeps cancellation context, publication date and textual changes', () => {
|
||
const items = parseNews(fixture('news'));
|
||
assert.equal(items.length, 3);
|
||
assert.equal(items[0].publishedDate, '2026-09-15');
|
||
assert.match(items[0].content, /отменяются следующие рейсы/);
|
||
assert.match(items[0].content, /21.09.2026/);
|
||
assert.match(items[2].content, /23:20.*23:00/);
|
||
assert.throws(() => parseSchedule(fixture('news'), items[0].url), /не страница/);
|
||
});
|
||
|
||
test('source fetch rejects external URLs and external redirect targets', async () => {
|
||
for (const url of ['https://example.com/ap2/', 'http://localhost/ap2/', 'https://ap2polotsk.of.by:8443/ap2/', 'https://x:pass@ap2polotsk.of.by/ap2/', 'file:///ap2/']) {
|
||
assert.throws(() => safeUrl(url), /Разрешены/);
|
||
}
|
||
let requests = 0;
|
||
await assert.rejects(fetchHtml(source.url, async () => {
|
||
requests++;
|
||
return new Response(null, { status: 302, headers: { location: 'https://example.com/ap2/' } });
|
||
}), /Разрешены/);
|
||
assert.equal(requests, 1);
|
||
await assert.rejects(fetchHtml(source.url, async () => new Response('error', { status: 503 })), /503/);
|
||
await assert.rejects(fetchHtml(source.url, async () => new Response('pdf', { headers: { 'content-type': 'application/pdf' } })), /не HTML/);
|
||
});
|
||
|
||
test('route 9 annotations preserve per-trip weekdays, exclusions and via stops', () => {
|
||
const annotated = parseSchedule(fixture('route9'), 'https://ap2polotsk.of.by/ap2/m-9/');
|
||
assert.deepEqual(annotated.warnings, []);
|
||
const state = { route: { id: 9, route_number: '9', name: 'Швейная фабрика → Стекловолокно' }, schedules: [],
|
||
stops: [{ stop_id: 10, name: 'Швейная фабрика' }, { stop_id: 11, name: 'Мариненко' }, { stop_id: 12, name: 'Полоцк – Стекловолокно' }] };
|
||
const forward = buildDirectionPlan(annotated, 'Швейная фабрика', state, '2026-09-16', '2026-09-16');
|
||
assert.deepEqual(forward.changes.map(c => c.after.length), [18, 5, 5]);
|
||
const trips = forward.changes[0].after;
|
||
assert.deepEqual(trips.find(t => t.departureTime === '06:05:00').viaStopIds, [11]);
|
||
assert.deepEqual(trips.find(t => t.departureTime === '15:50:00').daysOfWeek, [5]);
|
||
for (const time of ['15:55:00', '16:58:00', '18:00:00']) assert.deepEqual(trips.find(t => t.departureTime === time).exceptDaysOfWeek, [5]);
|
||
const reverse = buildDirectionPlan(annotated, 'Полоцк – Стекловолокно', { ...state, stops: [...state.stops].reverse() }, '2026-09-16', '2026-09-16');
|
||
assert.deepEqual(reverse.changes.map(c => c.after.length), [17, 5, 5]);
|
||
assert.deepEqual(reverse.changes[0].after.find(t => t.departureTime === '16:15:00').daysOfWeek, [5]);
|
||
for (const stops of [state.stops.filter(s => s.stop_id !== 11), [...state.stops, { stop_id: 13, name: 'Мариненко' }]]) {
|
||
const unbound = buildDirectionPlan(annotated, 'Швейная фабрика', { ...state, stops }, '2026-09-16', '2026-09-16');
|
||
assert.equal(unbound.notices.length, 1);
|
||
const via = unbound.changes[0].after.find(t => t.departureTime === '06:05:00');
|
||
assert.deepEqual(via.viaStopIds, []);
|
||
assert.equal(via.note, 'через «Мариненко»');
|
||
}
|
||
});
|
||
|
||
test('weekday grammar handles all ISO weekdays and combined conditions, rejects unknown suffixes', () => {
|
||
const { parseDeparture } = require('../src/services/scheduleSource');
|
||
['понедельникам', 'вторникам', 'средам', 'четвергам', 'пятницам', 'субботам', 'воскресеньям'].forEach((word, i) => {
|
||
assert.deepEqual(parseDeparture(`6:05 по ${word}`).daysOfWeek, [i + 1]);
|
||
});
|
||
['понедельника', 'вторника', 'среды', 'четверга', 'пятницы', 'субботы', 'воскресенья'].forEach((word, i) => {
|
||
assert.deepEqual(parseDeparture(`06:05 кроме ${word}`).exceptDaysOfWeek, [i + 1]);
|
||
});
|
||
const combined = parseDeparture('06:05 через «Мариненко» только по понедельникам, средам и пятницам кроме среды');
|
||
// Contradictory inclusion/exclusion is not silently normalized.
|
||
assert.equal(combined, null);
|
||
const valid = parseDeparture('06:05 по понедельникам и пятницам через Мариненко');
|
||
assert.deepEqual(valid.daysOfWeek, [1, 5]);
|
||
assert.deepEqual(valid.viaStopNames, ['Мариненко']);
|
||
for (const value of ['06:05 кроме праздников', '06:05 по пятницам временно', '06:05*', '06:05 через «Мариненко» до Кстов', '25:00', '06:05 кроме пятницы кроме среды']) {
|
||
assert.equal(parseDeparture(value), null, value);
|
||
}
|
||
const unknown = parseSchedule(fixture('route9').replace('по пятницам', 'кроме праздников'), 'https://ap2polotsk.of.by/ap2/m-9/');
|
||
assert.ok(unknown.warnings.length);
|
||
});
|
||
|
||
test('weekend day restrictions split correctly and contradictory day sections are blocked', () => {
|
||
const annotated = parseSchedule(fixture('route9').replace('06:10 </span> через «Мариненко»', '06:10 </span> кроме воскресенья'), 'https://ap2polotsk.of.by/ap2/m-9/');
|
||
const state = { route: { id: 9, route_number: '9' }, schedules: [], stops: [{ stop_id: 1, name: 'Швейная фабрика' }, { stop_id: 2, name: 'Полоцк – Стекловолокно' }] };
|
||
const built = buildDirectionPlan(annotated, 'Швейная фабрика', state, '2026-09-16', '2026-09-16');
|
||
assert.deepEqual(built.changes.map(c => c.after.length), [18, 5, 4]);
|
||
const invalid = parseSchedule(fixture('route9').replace('по пятницам', 'по субботам'), annotated.url);
|
||
assert.throws(() => buildDirectionPlan(invalid, 'Швейная фабрика', state, '2026-09-16', '2026-09-16'), /противоречит/);
|
||
});
|
||
|
||
test('route 24 splits Monday–Thursday, Friday and weekends without duplicate daily departures', () => {
|
||
const source24 = parseSchedule(fixture('route24'), 'https://ap2polotsk.of.by/ap2/m-24/');
|
||
assert.deepEqual(source24.warnings, []);
|
||
assert.equal(source24.directions.length, 2);
|
||
assert.deepEqual(departureStops(source24), ['Автовокзал', 'Полоцк – Стекловолокно']);
|
||
assert.deepEqual(source24.rows.map(row => row.daysOfWeek), [[1,2,3,4], [1,2,3,4], [5], [5], [], []]);
|
||
for (const direction of source24.directions) {
|
||
const snapshot24 = { route: { id: 24, route_number: '24' }, schedules: [], stops: direction.stops.map((name,i) => ({ name, stop_id: i + 1 })) };
|
||
const built = buildDirectionPlan(source24, direction.stops[0], snapshot24, '2026-09-16', '2026-09-16');
|
||
const weekdayTrips = built.changes.find(change => change.dayType === 'weekday').after;
|
||
assert.equal(new Set(weekdayTrips.map(trip => trip.departureTime)).size, weekdayTrips.length);
|
||
for (let day = 1; day <= 7; day++) {
|
||
const dayType = day <= 5 ? 'weekday' : day === 6 ? 'saturday' : 'sunday';
|
||
const active = built.changes.find(change => change.dayType === dayType).after
|
||
.filter(trip => (!trip.daysOfWeek.length || trip.daysOfWeek.includes(day)) && !trip.exceptDaysOfWeek.includes(day))
|
||
.map(trip => trip.departureTime).sort();
|
||
const row = source24.rows.find(row => row.stopName === direction.stops[0] && row.days.includes(dayType)
|
||
&& (!row.daysOfWeek.length || row.daysOfWeek.includes(day)));
|
||
assert.deepEqual(active, [...row.times].sort(), `${direction.label}, ISO day ${day}`);
|
||
}
|
||
const again = buildDirectionPlan(source24, direction.stops[0], { ...snapshot24, schedules: built.changes.map((change,i) => ({ id:i+1, day_type:change.dayType, trips:change.after, valid_from:'2026-09-16' })) }, '2026-09-16', '2026-09-16');
|
||
assert.equal(again.hasChanges, false);
|
||
}
|
||
});
|
||
|
||
test('section restrictions intersect trip notes and overlapping sections are rejected', () => {
|
||
const parsed = html => parseSchedule(html, 'https://ap2polotsk.of.by/ap2/m-24/');
|
||
const state = { route:{id:24,route_number:'24'}, schedules:[], stops:[{stop_id:1,name:'Автовокзал'},{stop_id:2,name:'Полоцк – Стекловолокно'}] };
|
||
const build = html => buildDirectionPlan(parsed(html), 'Автовокзал', state, '2026-09-16', '2026-09-16');
|
||
const narrowed = build(fixture('route24').replace('17:00', '17:00 кроме среды'));
|
||
assert.deepEqual(narrowed.changes[0].after.find(t => t.departureTime === '17:00:00').daysOfWeek, [1,2,4]);
|
||
assert.throws(() => build(fixture('route24').replace('17:00','17:00 по пятницам')), /противоречит/);
|
||
assert.throws(() => build(fixture('route24').replace('Понедельник – четверг','Понедельник – пятница')), /повторяющийся/);
|
||
for (const label of ['Понедельник-четверг', 'Понедельник — четверг']) assert.deepEqual(parsed(fixture('route24').replace('Понедельник – четверг',label)).warnings, []);
|
||
assert.ok(parsed(fixture('route24').replace('Понедельник – четверг','Четверг – понедельник')).warnings.length);
|
||
});
|