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', '06:15', '06:15']) {
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 через «Мариненко»', '06:10 кроме воскресенья'), '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);
});