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); });