feat: add schedule import functionality and quality control page

- Implemented the schedule import feature with a dedicated page for importing schedules from the transport website.
- Added a new QualityPage to check the quality of routes, stops, schedules, and notifications without making automatic changes.
- Created CSS styles for both the ScheduleImportPage and QualityPage to enhance the user interface.
- Introduced API calls for fetching schedule data and quality reports.
- Added error handling and loading states for better user experience during data fetching.
- Included functionality for filtering quality issues and displaying detailed reports.
This commit is contained in:
2026-09-16 21:55:50 +03:00
parent 6804ca5cdb
commit 595ad6456b
36 changed files with 2496 additions and 72 deletions

View File

@@ -0,0 +1,164 @@
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'), /противоречит/);
});