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:
@@ -4,7 +4,8 @@ const test = require('node:test');
|
||||
const {
|
||||
AlertCreationService,
|
||||
createAlertRequestHash,
|
||||
normalizeAlertInput
|
||||
normalizeAlertInput,
|
||||
isAlertDue
|
||||
} = require('../src/services/alertCreationService');
|
||||
|
||||
const ALERT_INPUT = {
|
||||
@@ -96,6 +97,22 @@ test('inactive alert is saved without dispatching a notification', async () => {
|
||||
assert.equal(notificationCalls, 0);
|
||||
});
|
||||
|
||||
test('future alert is saved but does not dispatch before its start time', async () => {
|
||||
let notificationCalls = 0;
|
||||
const future = new Date(Date.now() + 60 * 60 * 1000).toISOString();
|
||||
const service = new AlertCreationService({
|
||||
pool: { async query() { return { rows: [createStoredAlert({ start_time: new Date(future) })] }; } },
|
||||
notificationService: { async sendAlertCreated() { notificationCalls += 1; } },
|
||||
logger: createLogger()
|
||||
});
|
||||
|
||||
const result = await service.createAlert({ ...ALERT_INPUT, start_time: future });
|
||||
|
||||
assert.equal(result.created, true);
|
||||
assert.equal(notificationCalls, 0);
|
||||
assert.equal(isAlertDue(result.alert), false);
|
||||
});
|
||||
|
||||
test('database failure prevents notification dispatch', async () => {
|
||||
const databaseError = new Error('database unavailable');
|
||||
let notificationCalls = 0;
|
||||
|
||||
57
backend/test/alertSchedulerService.test.js
Normal file
57
backend/test/alertSchedulerService.test.js
Normal file
@@ -0,0 +1,57 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const test = require('node:test');
|
||||
const { AlertSchedulerService } = require('../src/services/alertSchedulerService');
|
||||
|
||||
function createLogger() {
|
||||
return { error() {} };
|
||||
}
|
||||
|
||||
test('scheduled delivery claims a due alert, sends it, then records delivery', async () => {
|
||||
const calls = [];
|
||||
const pool = {
|
||||
async query(sql, params = []) {
|
||||
const normalized = String(sql).replace(/\s+/g, ' ').trim();
|
||||
calls.push({ normalized, params });
|
||||
if (normalized.startsWith('WITH due AS')) {
|
||||
return { rows: [{
|
||||
id: 17, route_id: 3, route_number: '4', notification_route_number: '4',
|
||||
alert_type: 'detour', title: 'Объезд', message: 'Через центр',
|
||||
start_time: new Date(), end_time: null, is_active: true
|
||||
}] };
|
||||
}
|
||||
return { rows: [] };
|
||||
}
|
||||
};
|
||||
const notifications = [];
|
||||
const service = new AlertSchedulerService({
|
||||
pool,
|
||||
notificationService: {
|
||||
enabled: true,
|
||||
async sendAlertCreated(alert) {
|
||||
notifications.push(alert);
|
||||
return { sent: true, messageId: 'test-message' };
|
||||
}
|
||||
},
|
||||
logger: createLogger()
|
||||
});
|
||||
|
||||
const result = await service.dispatchDueAlerts();
|
||||
|
||||
assert.deepEqual(result, { dispatched: 1, claimed: 1 });
|
||||
assert.equal(notifications.length, 1);
|
||||
assert.equal(notifications[0].route_number, '4');
|
||||
assert.equal(calls.length, 2);
|
||||
assert.match(calls[1].normalized, /^UPDATE alerts SET notification_sent_at/);
|
||||
});
|
||||
|
||||
test('scheduler leaves the database untouched while notifications are disabled', async () => {
|
||||
let queries = 0;
|
||||
const service = new AlertSchedulerService({
|
||||
pool: { async query() { queries += 1; return { rows: [] }; } },
|
||||
notificationService: { enabled: false },
|
||||
logger: createLogger()
|
||||
});
|
||||
|
||||
assert.deepEqual(await service.dispatchDueAlerts(), { dispatched: 0, skipped: 'disabled' });
|
||||
assert.equal(queries, 0);
|
||||
});
|
||||
11
backend/test/fixtures/schedule-import/catalog.html
vendored
Normal file
11
backend/test/fixtures/schedule-import/catalog.html
vendored
Normal file
@@ -0,0 +1,11 @@
|
||||
<!-- Public source: ap2polotsk.of.by; captured 2026-09-16. -->
|
||||
<article id="post-52" class="post-52 page type-page status-publish hentry">
|
||||
<header class="entry-header"><h1 class="entry-title">Городские маршруты</h1></header><!-- .entry-header -->
|
||||
<div class="entry-content">
|
||||
<div><center>Выберите маршрут для просмотра расписания:</center></div>
|
||||
<div><center><a href="http://ap2polotsk.of.by/ap2/m-1/"><img decoding="async" src="http://ap2polotsk.of.by/ap2/wp-content/uploads/2016/01/1.png" onmouseover="this.src='http://ap2polotsk.of.by/ap2/wp-content/uploads/2016/01/1-1.png'" onmouseout="this.src='http://ap2polotsk.of.by/ap2/wp-content/uploads/2016/01/1.png'" alt="1" hspace="5" vspace="5"></a><a href="http://ap2polotsk.of.by/ap2/m-2/"><img decoding="async" src="http://ap2polotsk.of.by/ap2/wp-content/uploads/2016/01/2.png" onmouseover="this.src='http://ap2polotsk.of.by/ap2/wp-content/uploads/2016/01/2-1.png'" onmouseout="this.src='http://ap2polotsk.of.by/ap2/wp-content/uploads/2016/01/2.png'" alt="2" hspace="5" vspace="5"></a><a href="http://ap2polotsk.of.by/ap2/m-3/"><img decoding="async" src="http://ap2polotsk.of.by/ap2/wp-content/uploads/2016/01/3.png" onmouseover="this.src='http://ap2polotsk.of.by/ap2/wp-content/uploads/2016/01/3-1.png'" onmouseout="this.src='http://ap2polotsk.of.by/ap2/wp-content/uploads/2016/01/3.png'" alt="3" hspace="5" vspace="5"></a><a href="http://ap2polotsk.of.by/ap2/m-4/"><img decoding="async" src="http://ap2polotsk.of.by/ap2/wp-content/uploads/2016/01/4.png" onmouseover="this.src='http://ap2polotsk.of.by/ap2/wp-content/uploads/2016/01/4-1.png'" onmouseout="this.src='http://ap2polotsk.of.by/ap2/wp-content/uploads/2016/01/4.png'" alt="4" hspace="5" vspace="5"></a><a href="http://ap2polotsk.of.by/ap2/m-6/"><img decoding="async" src="http://ap2polotsk.of.by/ap2/wp-content/uploads/2016/01/6.png" onmouseover="this.src='http://ap2polotsk.of.by/ap2/wp-content/uploads/2016/01/6-1.png'" onmouseout="this.src='http://ap2polotsk.of.by/ap2/wp-content/uploads/2016/01/6.png'" alt="6" hspace="5" vspace="5"></a><a href="http://ap2polotsk.of.by/ap2/m-7/"><img decoding="async" src="http://ap2polotsk.of.by/ap2/wp-content/uploads/2016/01/7.png" onmouseover="this.src='http://ap2polotsk.of.by/ap2/wp-content/uploads/2016/01/7-1.png'" onmouseout="this.src='http://ap2polotsk.of.by/ap2/wp-content/uploads/2016/01/7.png'" alt="7" hspace="5" vspace="5"></a><a href="http://ap2polotsk.of.by/ap2/m-8/"><img decoding="async" src="http://ap2polotsk.of.by/ap2/wp-content/uploads/2016/01/8.png" onmouseover="this.src='http://ap2polotsk.of.by/ap2/wp-content/uploads/2016/01/8-1.png'" onmouseout="this.src='http://ap2polotsk.of.by/ap2/wp-content/uploads/2016/01/8.png'" alt="8" hspace="5" vspace="5"></a><a href="http://ap2polotsk.of.by/ap2/m-8a/"><img decoding="async" src="http://ap2polotsk.of.by/ap2/wp-content/uploads/2016/01/8a.png" onmouseover="this.src='http://ap2polotsk.of.by/ap2/wp-content/uploads/2016/01/8a-1.png'" onmouseout="this.src='http://ap2polotsk.of.by/ap2/wp-content/uploads/2016/01/8a.png'" alt="8" hspace="5" vspace="5"></a><a href="http://ap2polotsk.of.by/ap2/m-9/"><img decoding="async" src="http://ap2polotsk.of.by/ap2/wp-content/uploads/2016/01/9.png" onmouseover="this.src='http://ap2polotsk.of.by/ap2/wp-content/uploads/2016/01/9-1.png'" onmouseout="this.src='http://ap2polotsk.of.by/ap2/wp-content/uploads/2016/01/9.png'" alt="9" hspace="5" vspace="5"></a><a href="http://ap2polotsk.of.by/ap2/m-11/"><img decoding="async" src="http://ap2polotsk.of.by/ap2/wp-content/uploads/2016/01/11.png" onmouseover="this.src='http://ap2polotsk.of.by/ap2/wp-content/uploads/2016/01/11-1.png'" onmouseout="this.src='http://ap2polotsk.of.by/ap2/wp-content/uploads/2016/01/11.png'" alt="11" hspace="5" vspace="5"></a><a href="http://ap2polotsk.of.by/ap2/m-22/"><img decoding="async" src="http://ap2polotsk.of.by/ap2/wp-content/uploads/2016/01/22-00.png" onmouseover="this.src='http://ap2polotsk.of.by/ap2/wp-content/uploads/2016/01/22-01.png'" onmouseout="this.src='http://ap2polotsk.of.by/ap2/wp-content/uploads/2016/01/22-00.png'" alt="22" hspace="5" vspace="5"></a><a href="http://ap2polotsk.of.by/ap2/m-23/"><img decoding="async" src="http://ap2polotsk.of.by/ap2/wp-content/uploads/2016/01/23.png" onmouseover="this.src='http://ap2polotsk.of.by/ap2/wp-content/uploads/2016/01/23-1.png'" onmouseout="this.src='http://ap2polotsk.of.by/ap2/wp-content/uploads/2016/01/23.png'" alt="23" hspace="5" vspace="5"></a><a href="http://ap2polotsk.of.by/ap2/m-24/"><img decoding="async" src="http://ap2polotsk.of.by/ap2/wp-content/uploads/2016/01/24.png" onmouseover="this.src='http://ap2polotsk.of.by/ap2/wp-content/uploads/2016/01/24-1.png'" onmouseout="this.src='http://ap2polotsk.of.by/ap2/wp-content/uploads/2016/01/24.png'" alt="24" hspace="5" vspace="5"></a><a href="http://ap2polotsk.of.by/ap2/m-27/"><img decoding="async" src="http://ap2polotsk.of.by/ap2/wp-content/uploads/2016/01/27.png" onmouseover="this.src='http://ap2polotsk.of.by/ap2/wp-content/uploads/2016/01/27-1.png'" onmouseout="this.src='http://ap2polotsk.of.by/ap2/wp-content/uploads/2016/01/27.png'" alt="27" hspace="5" vspace="5"></a><br>
|
||||
<a href="http://ap2polotsk.of.by/ap2/rasp/gorod/m-28-00/"><img decoding="async" src="http://ap2polotsk.of.by/ap2/wp-content/uploads/2019/03/28-00.png" onmouseover="this.src='http://ap2polotsk.of.by/ap2/wp-content/uploads/2019/03/28-01.png'" onmouseout="this.src='http://ap2polotsk.of.by/ap2/wp-content/uploads/2019/03/28-00.png'" alt="28э" hspace="5" vspace="5"></a><br>
|
||||
<center><b><a href="http://ap2polotsk.of.by/ap2/deg/">ДЕЖУРНЫЕ</a></b></center><br>
|
||||
<center><a href="http://ap2polotsk.of.by/ap2/rasp-polotsk/">◄ к списку маршрутов</a></center><p></p>
|
||||
</center></div><!-- .entry-content -->
|
||||
</div></article>
|
||||
94
backend/test/fixtures/schedule-import/news.html
vendored
Normal file
94
backend/test/fixtures/schedule-import/news.html
vendored
Normal file
@@ -0,0 +1,94 @@
|
||||
<!-- Public source: ap2polotsk.of.by; captured 2026-09-16. -->
|
||||
<article id="post-8492" class="post-8492 post type-post status-publish format-standard hentry category-raspisanie">
|
||||
|
||||
<header class="entry-header">
|
||||
<h1 class="entry-title"><a href="https://ap2polotsk.of.by/ap2/2026/09/15/izmeneniya-v-raspisanii-dvizheniya-gorodskogo-transporta-21-09-2026-g-polock/" rel="bookmark">Изменения в расписании движения городского транспорта 21.09.2026 (г.Полоцк)</a></h1>
|
||||
<div class="entry-meta">
|
||||
<span class="entry-date"><a href="https://ap2polotsk.of.by/ap2/2026/09/15/izmeneniya-v-raspisanii-dvizheniya-gorodskogo-transporta-21-09-2026-g-polock/" rel="bookmark"><time class="entry-date" datetime="2026-09-15T13:12:35+03:00">15.09.2026</time></a></span> <span class="byline"><span class="author vcard"><a class="url fn n" href="https://ap2polotsk.of.by/ap2/author/root/" rel="author">.</a></span></span> </div><!-- .entry-meta -->
|
||||
</header><!-- .entry-header -->
|
||||
|
||||
<div class="entry-content">
|
||||
<p>В связи с ремонтом ж/д переезда по ул. Красноармейской 21.09.2026 года с 09:00 до 18:00 в схему и расписание движения городских автобусов вносятся следующие изменения:<br>
|
||||
– отменяются следующие рейсы:</p>
|
||||
<table>
|
||||
<tbody>
|
||||
<tr bgcolor="#00B7FF">
|
||||
<td colspan="2">
|
||||
<p><strong>Маршрут №8 «Автовокзал – Бабушкина»</strong></p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<p>Швейная фабрика</p>
|
||||
</td>
|
||||
<td>
|
||||
<p>08:50 | 09:30 | 10:00 | 10:30 | 12:10 | 13:40 | 14:40 | 15:10 |</p>
|
||||
<p>15:50 | 16:44 | 17:20 |</p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<p>Бабушкина</p>
|
||||
</td>
|
||||
<td>
|
||||
<p>09:06 | 09:46 | 10:20 | 10:50 | 11:40 | 12:30 | 14:00 | 15:00 |</p>
|
||||
<p>15:30 | 16:10 | 17:00 | 17:40 |</p>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<table>
|
||||
<tbody>
|
||||
<tr bgcolor="#00B7FF">
|
||||
<td colspan="2">
|
||||
<p><strong>Маршрут №8А«Автовокзал – Бабушкина ч/з Аэропорт»</strong></p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<p>Автовокзал</p>
|
||||
</td>
|
||||
<td>
|
||||
<p>10:55 | 13:20 | 16:30 |</p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<p>Бабушкина</p>
|
||||
</td>
|
||||
<td>
|
||||
<p>14:20 | 17:20 |</p>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div><!-- .entry-content -->
|
||||
|
||||
</article>
|
||||
<article id="post-8486" class="post-8486 post type-post status-publish format-standard hentry category-raspisanie">
|
||||
|
||||
<header class="entry-header">
|
||||
<h1 class="entry-title"><a href="https://ap2polotsk.of.by/ap2/2026/09/14/izmeneniya-v-raspisanii-prigorodnogo-sezonnogo-transporta-s-18-09-2026-g-polock/" rel="bookmark">Изменения в расписании пригородного сезонного транспорта с 18.09.2026 (г. Полоцк)</a></h1>
|
||||
<div class="entry-meta">
|
||||
<span class="entry-date"><a href="https://ap2polotsk.of.by/ap2/2026/09/14/izmeneniya-v-raspisanii-prigorodnogo-sezonnogo-transporta-s-18-09-2026-g-polock/" rel="bookmark"><time class="entry-date" datetime="2026-09-14T10:46:05+03:00">14.09.2026</time></a></span> <span class="byline"><span class="author vcard"><a class="url fn n" href="https://ap2polotsk.of.by/ap2/author/root/" rel="author">.</a></span></span> </div><!-- .entry-meta -->
|
||||
</header><!-- .entry-header -->
|
||||
|
||||
<div class="entry-content">
|
||||
<p>С 18.09.2026 по пятницам отменяется рейс автобуса по пригородному сезонному маршруту №256с Полоцк – Казимирово, отправлением с автовокзала в 18:40, с остановки Казимирово в 18:25.</p>
|
||||
</div><!-- .entry-content -->
|
||||
|
||||
</article>
|
||||
<article id="post-8468" class="post-8468 post type-post status-publish format-standard hentry category-raspisanie">
|
||||
|
||||
<header class="entry-header">
|
||||
<h1 class="entry-title"><a href="https://ap2polotsk.of.by/ap2/2026/09/08/s-14-09-2026-vnosyatsya-izmeneniya-v-raspisanie-dvizheniya-po-sledujushhim-gorodskim-marshrutam/" rel="bookmark">С 14.09.2026 вносятся изменения в расписание движения по следующим городским маршрутам:</a></h1>
|
||||
<div class="entry-meta">
|
||||
<span class="entry-date"><a href="https://ap2polotsk.of.by/ap2/2026/09/08/s-14-09-2026-vnosyatsya-izmeneniya-v-raspisanie-dvizheniya-po-sledujushhim-gorodskim-marshrutam/" rel="bookmark"><time class="entry-date" datetime="2026-09-08T10:25:51+03:00">08.09.2026</time></a></span> <span class="byline"><span class="author vcard"><a class="url fn n" href="https://ap2polotsk.of.by/ap2/author/root/" rel="author">.</a></span></span> </div><!-- .entry-meta -->
|
||||
</header><!-- .entry-header -->
|
||||
|
||||
<div class="entry-content">
|
||||
<p>Маршрут №22 «Мариненко – Больница (Новка) ч/з Аэропорт» по будним и выходным дням:<br>
|
||||
— рейс, отправлением от остановочного пункта «Больница (Новка)» в 23:20, переносится на 23:00.</p>
|
||||
</div><!-- .entry-content -->
|
||||
|
||||
</article>
|
||||
42
backend/test/fixtures/schedule-import/route1.html
vendored
Normal file
42
backend/test/fixtures/schedule-import/route1.html
vendored
Normal file
@@ -0,0 +1,42 @@
|
||||
<!-- Public source: ap2polotsk.of.by; captured 2026-09-16. -->
|
||||
<article id="post-153" class="post-153 page type-page status-publish hentry">
|
||||
<header class="entry-header"><h1 class="entry-title">Маршрут №1</h1></header><!-- .entry-header -->
|
||||
<div class="entry-content">
|
||||
<table>
|
||||
<caption> <a href="http://ap2polotsk.of.by/ap2/polotsk-gorod/">◄ к списку маршрутов</a> </caption>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td colspan="2" bgcolor="#00B7FF"> <center> <strong> Рабочий день </strong> </center></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td> Швейная фабрика</td>
|
||||
<td><span class="notranslate">05:20 | 06:30 | 06:50 | 07:30 | 08:00 | 08:38 | 09:05 | 09:55 | 11:25 | 13:45 | 14:55 | 15:35 | 16:10 | 16:45 | 17:35 | 18:15 | 19:10 | 20:20</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td> Богатырская</td>
|
||||
<td><span class="notranslate">05:40 | 06:50 | 07:20 | 08:05 | 08:33 | 09:10 | 09:37 | 10:25 | 11:55 | 14:10 | 15:25 | 16:10 | 16:40 | 17:10 | 17:55 | 18:35 | 19:30 | 20:37</span></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<table>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td colspan="2" bgcolor="#FFF56E"> <center> <strong> Выходной день </strong> </center></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td> Швейная фабрика</td>
|
||||
<td><span class="notranslate">06:05 | 07:06 | 08:10 | 09:12 | 10:10 | 11:10 | 12:12 | 13:10 | 14:20 | 15:30 | 16:45 | 17:50 | 19:12 | 20:20</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td> Богатырская</td>
|
||||
<td><span class="notranslate">06:35 | 07:25 | 08:40 | 09:45 | 10:40 | 11:40 | 12:42 | 13:45 | 14:50 | 16:05 | 17:15 | 18:25 | 19:33 | 20:37</span></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div style="text-indent: 40px;" align="justify"><strong>Остановочные пункты:</strong></div>
|
||||
<p style="text-indent: 40px;" align="justify">Прямое направление: <span class="notranslate"> «</span>Швейная фабрика<span class="notranslate">»</span>, <span class="notranslate"> «</span>Типография<span class="notranslate">»</span>, <span class="notranslate"> «</span>Суворова<span class="notranslate">»</span>, <span class="notranslate"> «</span>Комсомольская<span class="notranslate">»</span>, <span class="notranslate"> «</span>Громы<span class="notranslate">»</span>, <span class="notranslate"> «</span>Некрасова<span class="notranslate">»</span>, <span class="notranslate"> «</span>Сортировочная<span class="notranslate">»</span>, <span class="notranslate"> «</span>КимаБел<span class="notranslate">»</span>, <span class="notranslate"> «</span>Центральная<span class="notranslate">»</span>, <span class="notranslate"> «</span>Богатырская<span class="notranslate">»</span>.</p>
|
||||
<p style="text-indent: 40px;" align="justify">Обратное направление: <span class="notranslate"> «</span>Богатырская<span class="notranslate">»</span>, <span class="notranslate"> «</span>Центральная<span class="notranslate">»</span>, <span class="notranslate"> «</span>КимаБел<span class="notranslate">»</span>, <span class="notranslate"> «</span>Сортировочная<span class="notranslate">»</span>, <span class="notranslate"> «</span>Некрасова<span class="notranslate">»</span>, <span class="notranslate"> «</span>Громы<span class="notranslate">»</span>, <span class="notranslate"> «</span>Комсомольская<span class="notranslate">»</span>, <span class="notranslate"> «</span>Суворова<span class="notranslate">»</span>, <span class="notranslate"> «</span>Типография<span class="notranslate">»</span>, <span class="notranslate"> «</span>Союзпечать<span class="notranslate">»</span>, <span class="notranslate"> «</span>Автовокзал<span class="notranslate">»</span>.</p>
|
||||
<div><center><b>Схема движения</b></center></div>
|
||||
<p><a href="http://ap2polotsk.of.by/ap2/wp-content/uploads/2022/04/1-shema.png"><img decoding="async" class="aligncenter" src="http://ap2polotsk.of.by/ap2/wp-content/uploads/2022/04/1-shema.png"></a></p>
|
||||
</div><!-- .entry-content -->
|
||||
</article>
|
||||
58
backend/test/fixtures/schedule-import/route2.html
vendored
Normal file
58
backend/test/fixtures/schedule-import/route2.html
vendored
Normal file
@@ -0,0 +1,58 @@
|
||||
<!-- Public source: ap2polotsk.of.by; captured 2026-09-16. -->
|
||||
<article id="post-167" class="post-167 page type-page status-publish hentry">
|
||||
<header class="entry-header"><h1 class="entry-title">Маршрут №2</h1></header><!-- .entry-header -->
|
||||
<div class="entry-content">
|
||||
<table>
|
||||
<caption> <a href="http://ap2polotsk.of.by/ap2/polotsk-gorod/">◄ к списку маршрутов</a> </caption>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td colspan="2" bgcolor="#00B7FF"> <center> <strong> Рабочий день </strong> </center></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td> Больница (Ксты)</td>
|
||||
<td><span class="notranslate">06:15 | 06:45 | 07:15 | 07:45 | 08:15 | 08:45 | 09:35 | 10:30 | 11:55 | 13:35 | 14:25 | 15:25 | 16:20 | 16:55 | 17:10 | 18:10 | 20:10</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td> Швейная фабрика</td>
|
||||
<td><span class="notranslate">06:36 | 07:06 | 07:36 | 08:06 | 08:36 | 09:06 | 09:56 | 10:51 | 12:16 | 13:56 | 14:46 | 15:46 | 16:41 | 17:16 | 17:31 | 18:31 | 20:31</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td> Больница (Новка)</td>
|
||||
<td><span class="notranslate">05:50 | 06:50 | 07:20 | 08:00 | 08:25 | 09:00 | 09:30 | 10:15 | 11:15 | 12:45 | 14:15 | 15:15 | 16:15 | 17:00 | 17:35 | 18:55</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td> Автовокзал</td>
|
||||
<td><span class="notranslate">06:02 | 07:02 | 07:32 | 08:12 | 08:37 | 09:12 | 09:42 | 10:27 | 11:27 | 12:57 | 14:27 | 15:27 | 16:27 | 17:12 | 17:47 | 19:07</span></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<table id="table1">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td colspan="2" bgcolor="#FFF56E"> <center> <strong> Выходной день </strong> </center></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td> Больница (Ксты)</td>
|
||||
<td><span class="notranslate">05:23 | 07:07 | 07:57 | 08:47 | 09:43 | 10:37 | 11:34 | 12:30 | 13:31 | 14:24 | 15:31 | 16:25 | 17:22 | 18:18 | 18:55 | 20:10</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td> Швейная фабрика</td>
|
||||
<td><span class="notranslate">05:44 | 07:28 | 08:18 | 09:08 | 10:04 | 10:58 | 11:55 | 12:51 | 13:52 | 14:45 | 15:52 | 16:46 | 17:43 | 18:39 | 19:16 | 20:31</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td> Больница (Новка)</td>
|
||||
<td><span class="notranslate">05:50 | 07:10 | 08:10 | 10:00 | 10:57 | 11:56 | 12:48 | 13:45 | 14:12 | 15:40 | 16:37 | 17:42 | 18:04 | 19:37</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td> Автовокзал</td>
|
||||
<td><span class="notranslate">06:02 | 07:22 | 08:22 | 10:12 | 11:09 | 12:08 | 13:00 | 13:57 | 14:24 | 15:52 | 16:49 | 17:54 | 18:16 | 19:49</span></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div style="text-indent: 40px;" align="justify"><strong>Остановочные пункты:</strong></div>
|
||||
<p style="text-indent: 40px;" align="justify">Прямое направление: <span class="notranslate"> «</span>Больница (Ксты)<span class="notranslate">»</span>, <span class="notranslate"> «</span>Курган Бессмертия<span class="notranslate">»</span>, <span class="notranslate"> «</span>Экимань<span class="notranslate">»</span>, <span class="notranslate"> «</span>Заполотье<span class="notranslate">»</span>, <span class="notranslate"> «</span>Энергосбыт<span class="notranslate">»</span>, <span class="notranslate"> «</span>Спортклуб<span class="notranslate">»</span>, <span class="notranslate"> «</span>Олимпиец<span class="notranslate">»</span>, <span class="notranslate"> «</span>Экономический колледж<span class="notranslate">»</span>, <span class="notranslate"> «</span>Педагогический колледж<span class="notranslate">»</span>, <span class="notranslate"> «</span>Райисполком<span class="notranslate">»</span>, <span class="notranslate"> «</span>Поликлиника<span class="notranslate">»</span>, <span class="notranslate"> «</span>Дом торговли<span class="notranslate">»</span>, <span class="notranslate"> «</span>Швейная фабрика<span class="notranslate">»</span>, <span class="notranslate"> «</span>Типография<span class="notranslate">»</span>, <span class="notranslate"> «</span>Красное кладбище<span class="notranslate">»</span>, <span class="notranslate"> «</span>Гагарина<span class="notranslate">»</span>, <span class="notranslate"> «</span>Ремонтник<span class="notranslate">»</span>, <span class="notranslate"> «</span>Проезд Гагарина<span class="notranslate">»</span>, <span class="notranslate"> «</span>Центр детей и молодежи<span class="notranslate">»</span>, <span class="notranslate"> «</span>Калиновского<span class="notranslate">»</span>, <span class="notranslate"> «</span>Больница (Новка)<span class="notranslate">»</span>.</p>
|
||||
<p style="text-indent: 40px;" align="justify">Обратное направление: <span class="notranslate"> «</span>Больница (Новка)<span class="notranslate">»</span>, <span class="notranslate"> «</span>Калиновского<span class="notranslate">»</span>, <span class="notranslate"> «</span>Центр детей и молодежи<span class="notranslate">»</span>, <span class="notranslate"> «</span>Проезд Гагарина<span class="notranslate">»</span>, <span class="notranslate"> «</span>Ремонтник<span class="notranslate">»</span>, <span class="notranslate"> «</span>Гагарина<span class="notranslate">»</span>, <span class="notranslate"> «</span>Типография<span class="notranslate">»</span>, <span class="notranslate"> «</span>Союзпечать<span class="notranslate">»</span>, <span class="notranslate"> «</span>Автовокзал<span class="notranslate">»</span>, <span class="notranslate"> «</span>Дом торговли<span class="notranslate">»</span>, <span class="notranslate"> «</span>Поликлиника<span class="notranslate">»</span>, <span class="notranslate"> «</span>Педагогический колледж<span class="notranslate">»</span>, <span class="notranslate"> «</span>Экономический колледж<span class="notranslate">»</span>, <span class="notranslate"> «</span>Олимпиец<span class="notranslate">»</span>, <span class="notranslate"> «</span>Спортклуб<span class="notranslate">»</span>, <span class="notranslate"> «</span>Энергосбыт<span class="notranslate">»</span>, <span class="notranslate"> «</span>Заполотье<span class="notranslate">»</span>, <span class="notranslate"> «</span>Экимань<span class="notranslate">»</span>, <span class="notranslate"> «</span>Курган Бессмертия<span class="notranslate">»</span>, <span class="notranslate"> «</span>Больница (Ксты)<span class="notranslate">»</span>.</p>
|
||||
<div><center><b>Схема движения</b></center></div>
|
||||
<p><a href="http://ap2polotsk.of.by/ap2/wp-content/uploads/2020/11/2-shema.png"><img decoding="async" class="aligncenter" src="http://ap2polotsk.of.by/ap2/wp-content/uploads/2020/11/2-shema.png"></a></p>
|
||||
</div><!-- .entry-content -->
|
||||
</article>
|
||||
40
backend/test/fixtures/schedule-import/route9.html
vendored
Normal file
40
backend/test/fixtures/schedule-import/route9.html
vendored
Normal file
@@ -0,0 +1,40 @@
|
||||
<h1 class="entry-title">Маршрут №9</h1><div class="entry-content">
|
||||
<table>
|
||||
<caption> <a href="http://ap2polotsk.of.by/ap2/polotsk-gorod/">◄ к списку маршрутов</a> </caption>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td colspan="2" bgcolor="#00B7FF"> <center> <strong> Рабочий день </strong> </center></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td> Швейная фабрика</td>
|
||||
<td><span class="notranslate">06:05 </span> через «Мариненко» <span class="notranslate"> | 06:20 | 07:08 | 07:40 | 07:55 | 08:55 | 11:00 | 13:00 | 14:00 | 14:10 | 15:10 | 15:50 </span> по пятницам <span class="notranslate"> | 15:55 </span> кроме пятницы <span class="notranslate"> | 16:52 | 16:58 </span> кроме пятницы <span class="notranslate"> | 18:00 </span> кроме пятницы <span class="notranslate"> | 19:10 | 22:05 </span> через «Мариненко»</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td> Полоцк – Стекловолокно</td>
|
||||
<td><span class="notranslate">06:40 | 07:15 | 07:31 </span> через «Мариненко» <span class="notranslate"> | 08:25 | 09:20 | 11:25 | 13:25 | 14:40 | 15:25 | 15:35 </span> кроме пятницы <span class="notranslate"> | 16:15 </span> по пятницам <span class="notranslate"> | 16:25 | 17:25 </span> кроме пятницы <span class="notranslate"> | 17:30 | 18:30 </span> кроме пятницы <span class="notranslate"> | 20:20 | 23:30 </span> через «Мариненко»</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<table>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td colspan="2" bgcolor="#FFF56E"><b><center>Выходной день</center></b></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
Швейная фабрика</td>
|
||||
<td><span class="notranslate">06:10 </span> через «Мариненко» <span class="notranslate"> | 07:20 | 14:00 | 19:10 | 22:10 </span> через «Мариненко»</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
Полоцк – Стекловолокно</td>
|
||||
<td><span class="notranslate">07:25 </span> через «Мариненко» <span class="notranslate"> | 08:25 | 15:35 | 20:20 | 23:25 </span> через «Мариненко»</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div style="text-indent: 40px;" align="justify"><strong>Остановочные пункты:</strong></div>
|
||||
<p style="text-indent: 40px;" align="justify">Прямое направление: <span class="notranslate"> «</span>Швейная фабрика<span class="notranslate">»</span>, <span class="notranslate"> «</span>Юбилейная<span class="notranslate">»</span>, <span class="notranslate"> «</span>Кульнева<span class="notranslate">»</span>, <span class="notranslate"> «</span>Селянская<span class="notranslate">»</span>, <span class="notranslate"> «</span>23 Гвардейцев<span class="notranslate">»</span>, <span class="notranslate"> «</span>Петруся Бровки<span class="notranslate">»</span>, <span class="notranslate"> «</span>Развилка<span class="notranslate">»</span>, <span class="notranslate"> «</span>Вильнюсское шоссе<span class="notranslate">»</span>, <span class="notranslate"> «</span>Ксты<span class="notranslate">»</span>, <span class="notranslate"> «</span>Полоцкгаз<span class="notranslate">»</span>, <span class="notranslate"> «</span>Полоцк-Стекловолокно<span class="notranslate">»</span>.</p>
|
||||
<p style="text-indent: 40px;" align="justify">Обратное направление: <span class="notranslate"> «</span>Полоцк-Стекловолокно<span class="notranslate">»</span>, <span class="notranslate"> «</span>Полоцкгаз<span class="notranslate">»</span>, <span class="notranslate"> «</span>Ксты<span class="notranslate">»</span>, <span class="notranslate"> «</span>Вильнюсское шоссе<span class="notranslate">»</span>, <span class="notranslate"> «</span>ТРЦ Манеж<span class="notranslate">»</span>, <span class="notranslate"> «</span>Развилка<span class="notranslate">»</span>, <span class="notranslate"> «</span>Петруся Бровки<span class="notranslate">»</span>, <span class="notranslate"> «</span>23 Гвардейцев<span class="notranslate">»</span>, <span class="notranslate"> «</span>Селянская<span class="notranslate">»</span>, <span class="notranslate"> «</span>Кульнева<span class="notranslate">»</span>, <span class="notranslate"> «</span>Юбилейная<span class="notranslate">»</span>, <span class="notranslate"> «</span>Автовокзал<span class="notranslate">»</span>.</p>
|
||||
<div><center><b>Схема движения</b></center></div>
|
||||
<p><a href="http://ap2polotsk.of.by/ap2/wp-content/uploads/2026/09/9-shema-1.png"><img decoding="async" class="aligncenter" src="http://ap2polotsk.of.by/ap2/wp-content/uploads/2026/09/9-shema-1.png"></a></p>
|
||||
</div>
|
||||
22
backend/test/qualityReport.test.js
Normal file
22
backend/test/qualityReport.test.js
Normal file
@@ -0,0 +1,22 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const test = require('node:test');
|
||||
const { buildQualityReport, qualityChecks } = require('../src/services/qualityReport');
|
||||
|
||||
test('quality report makes actionable, stable issues and summary', () => {
|
||||
const rows = Object.fromEntries(qualityChecks.map(({ name }) => [name, []]));
|
||||
rows.routesWithoutStops.push({ route_id: 8, route_number: '4', route_name: 'Вокзал → Центр', active_stops_count: 0 });
|
||||
rows.missingSchedules.push({ route_id: 8, route_number: '4', route_name: 'Вокзал → Центр', day_type: 'weekday' });
|
||||
rows.overlappingSchedules.push({ route_id: 9, route_number: '5', route_name: 'А → Б', day_type: 'sunday', schedule_id: 12, conflicting_schedule_id: 10 });
|
||||
rows.unusedStops.push({ stop_id: 3, stop_name: 'Резервная' });
|
||||
rows.expiredAlerts.push({ alert_id: 15, title: 'Временный объезд' });
|
||||
|
||||
const report = buildQualityReport(rows, '2026-09-16T10:00:00.000Z');
|
||||
|
||||
assert.deepEqual(report.summary, { total: 5, critical: 3, warning: 1, info: 1 });
|
||||
assert.equal(report.checked_at, '2026-09-16T10:00:00.000Z');
|
||||
assert.deepEqual(report.issues.map((issue) => issue.severity), ['critical', 'critical', 'critical', 'warning', 'info']);
|
||||
assert.deepEqual(report.issues.find((issue) => issue.code === 'missing_schedule').action, {
|
||||
to: '/schedules?route=8', label: 'Открыть расписания',
|
||||
});
|
||||
assert.match(report.issues.find((issue) => issue.code === 'overlapping_schedules').description, /№12 и №10/);
|
||||
});
|
||||
162
backend/test/scheduleImport.integration.test.js
Normal file
162
backend/test/scheduleImport.integration.test.js
Normal file
@@ -0,0 +1,162 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs/promises');
|
||||
const path = require('node:path');
|
||||
const { Pool } = require('pg');
|
||||
const { ScheduleImportService } = require('../src/services/scheduleImportService');
|
||||
const { getLocalDateTime } = require('../src/utils/etaTime');
|
||||
const { parseSchedule, CATALOG_URL, HOME_URL } = require('../src/services/scheduleSource');
|
||||
|
||||
// Opt-in only. Use a disposable database; every run gets its own temporary schema.
|
||||
test('PostgreSQL import lifecycle, concurrency, conflicts, rollback and news review', {
|
||||
skip: !process.env.IMPORT_TEST_DATABASE_URL,
|
||||
}, async () => {
|
||||
const url = process.env.IMPORT_TEST_DATABASE_URL;
|
||||
assert.match(new URL(url).pathname, /test/i, 'Use a dedicated test database');
|
||||
const admin = new Pool({ connectionString: url });
|
||||
const schema = `import_test_${Date.now()}`;
|
||||
let pool;
|
||||
try {
|
||||
await admin.query('CREATE EXTENSION IF NOT EXISTS postgis');
|
||||
await admin.query(`CREATE SCHEMA ${schema}`);
|
||||
pool = new Pool({ connectionString: url, options: `-c search_path=${schema},public` });
|
||||
for (const file of (await fs.readdir(path.join(__dirname, '../migrations'))).filter(f => f.endsWith('.sql')).sort()) {
|
||||
await pool.query(await fs.readFile(path.join(__dirname, '../migrations', file), 'utf8'));
|
||||
}
|
||||
const fixture = name => fs.readFile(path.join(__dirname, 'fixtures/schedule-import', `${name}.html`), 'utf8');
|
||||
let sourceHtml = await fixture('route1');
|
||||
let newsHtml = await fixture('news');
|
||||
const catalogHtml = await fixture('catalog');
|
||||
const sourceUrl = 'https://ap2polotsk.of.by/ap2/m-1/';
|
||||
const load = async url => ({ url, html: url === CATALOG_URL ? catalogHtml : url === HOME_URL ? newsHtml : sourceHtml });
|
||||
const invalidations = [];
|
||||
const service = new ScheduleImportService({ pool, load, invalidate: async id => invalidations.push(id) });
|
||||
const source = parseSchedule(sourceHtml, sourceUrl);
|
||||
sourceHtml = sourceHtml.replace('05:20', `05:20 через «${source.directions[0].stops[1]}»`)
|
||||
.replace('06:30', '06:30 по пятницам').replace('06:50', '06:50 кроме пятницы')
|
||||
.replace('07:30', '07:30 через «Нет в базе»');
|
||||
const routeIds = [];
|
||||
for (const direction of source.directions) {
|
||||
const routeId = (await pool.query("INSERT INTO routes (route_number,name,type) VALUES ('1',$1,'bus') RETURNING id", [direction.stops[0] + ' → ' + direction.stops.at(-1)])).rows[0].id;
|
||||
routeIds.push(routeId);
|
||||
for (const [i, name] of direction.stops.entries()) {
|
||||
const stopId = (await pool.query("INSERT INTO stops (name,location) VALUES ($1,ST_GeogFromText('POINT(28.77 55.48)')) RETURNING id", [name])).rows[0].id;
|
||||
await pool.query('INSERT INTO route_stops (route_id,stop_id,sequence,time_offset_minutes) VALUES ($1,$2,$3,$4)', [routeId, stopId, i + 1, i * 2]);
|
||||
}
|
||||
}
|
||||
const routeId = routeIds[0];
|
||||
const validFrom = getLocalDateTime().date;
|
||||
const prepare = async () => {
|
||||
const inspection = await service.inspect({ routeIds, sourceUrl, userId: 1 });
|
||||
return service.preview({ inspectionId: inspection.id, mappings: inspection.mappings, validFrom, userId: 1 });
|
||||
};
|
||||
|
||||
const single = await service.inspect({ routeIds: [routeId], sourceUrl, userId: 1 });
|
||||
assert.equal(single.directions.length, 1);
|
||||
assert.equal(single.mappings[routeId], 'Швейная фабрика');
|
||||
await assert.rejects(service.preview({ inspectionId: single.id, mappings: { ...single.mappings, [routeIds[1]]: 'Богатырская' }, validFrom, userId: 1 }), /каждое выбранное/);
|
||||
await assert.rejects(service.inspect({ routeIds: [], sourceUrl, userId: 1 }), /направления/);
|
||||
await assert.rejects(service.inspect({ routeIds: [routeId, routeId], sourceUrl, userId: 1 }), /направления/);
|
||||
const other = (await pool.query("INSERT INTO routes (route_number,name,type) VALUES ('99','Другой маршрут','bus') RETURNING id")).rows[0].id;
|
||||
await assert.rejects(service.inspect({ routeIds: [routeId, other], sourceUrl, userId: 1 }), /тот же номер/);
|
||||
const first = await prepare();
|
||||
assert.equal(first.hasChanges, true);
|
||||
assert.deepEqual(first.plans.map(p => p.changes.map(c => c.after.length)), [[18, 14, 14], [18, 14, 14]]);
|
||||
assert.deepEqual(first.plans.map(p => p.sourceStopName), ['Швейная фабрика', 'Богатырская']);
|
||||
const concurrent = await Promise.all([service.apply(first.id), service.apply(first.id)]);
|
||||
assert.equal(concurrent.filter(r => r.alreadyApplied).length, 1);
|
||||
assert.equal(Number((await pool.query('SELECT count(*) FROM schedules')).rows[0].count), 6);
|
||||
assert.deepEqual(invalidations.sort((a,b) => a-b), routeIds);
|
||||
const savedTrips = (await pool.query("SELECT trips FROM schedules WHERE route_id=$1 AND day_type='weekday'", [routeId])).rows[0].trips;
|
||||
assert.equal(savedTrips.find(t => t.departureTime === '05:20:00').viaStopIds.length, 1);
|
||||
assert.deepEqual(savedTrips.find(t => t.departureTime === '06:30:00').daysOfWeek, [5]);
|
||||
assert.deepEqual(savedTrips.find(t => t.departureTime === '06:50:00').exceptDaysOfWeek, [5]);
|
||||
assert.equal(savedTrips.find(t => t.departureTime === '07:30:00').note, 'через «Нет в базе»');
|
||||
assert.equal((await service.history()).length, 1);
|
||||
assert.equal((await service.history())[0].directions.length, 2);
|
||||
assert.equal((await prepare()).hasChanges, false);
|
||||
assert.equal((await service.apply(first.id)).alreadyApplied, true);
|
||||
assert.equal(Number((await pool.query("SELECT count(*) FROM sync_log WHERE table_name='schedules'")).rows[0].count), 6);
|
||||
|
||||
sourceHtml = sourceHtml.replace('05:20', '05:21');
|
||||
const changed = await prepare();
|
||||
sourceHtml = sourceHtml.replace('05:21', '05:22');
|
||||
await assert.rejects(service.apply(changed.id), /на сайте изменилось/);
|
||||
const stale = await prepare();
|
||||
await pool.query("UPDATE schedules SET trips = jsonb_set(trips, '{0,note}', '\"Ручная правка\"'::jsonb) WHERE day_type = 'weekday'");
|
||||
const beforeConflict = JSON.stringify((await pool.query('SELECT * FROM schedules ORDER BY id')).rows);
|
||||
await assert.rejects(service.apply(stale.id), /после предпросмотра/);
|
||||
assert.equal(JSON.stringify((await pool.query('SELECT * FROM schedules ORDER BY id')).rows), beforeConflict);
|
||||
|
||||
// Failure while writing the second direction must also roll back the first direction.
|
||||
sourceHtml = sourceHtml.replace('05:40', '05:41');
|
||||
const toFail = await prepare();
|
||||
const beforeFailure = JSON.stringify((await pool.query('SELECT * FROM schedules ORDER BY id')).rows);
|
||||
const failingPool = { query: (...args) => pool.query(...args), connect: async () => {
|
||||
const client = await pool.connect();
|
||||
let updates = 0;
|
||||
return { release: () => client.release(), query: (sql, args) => {
|
||||
if (sql.startsWith('UPDATE schedules SET departure_times') && ++updates === 2) throw new Error('Injected failure');
|
||||
return client.query(sql, args);
|
||||
} };
|
||||
} };
|
||||
await assert.rejects(new ScheduleImportService({ pool: failingPool, load }).apply(toFail.id), /Injected failure/);
|
||||
assert.equal(JSON.stringify((await pool.query('SELECT * FROM schedules ORDER BY id')).rows), beforeFailure);
|
||||
assert.equal((await service.getDraft(toFail.id)).status, 'ready');
|
||||
await service.apply(toFail.id);
|
||||
|
||||
const reverseBefore = JSON.stringify((await pool.query('SELECT * FROM schedules WHERE route_id=$1 ORDER BY id', [routeIds[1]])).rows);
|
||||
await pool.query("UPDATE schedules SET trips = jsonb_set(trips, '{0,note}', '\"Правка только прямого направления\"'::jsonb) WHERE day_type = 'weekday' AND route_id=$1", [routeId]);
|
||||
const oneInspection = await service.inspect({ routeIds: [routeId], sourceUrl, userId: 1 });
|
||||
const onePreview = await service.preview({ inspectionId: oneInspection.id, mappings: oneInspection.mappings, validFrom, userId: 1 });
|
||||
assert.equal(onePreview.plans.length, 1);
|
||||
assert.equal(onePreview.hasChanges, true);
|
||||
await service.apply(onePreview.id);
|
||||
assert.equal(JSON.stringify((await pool.query('SELECT * FROM schedules WHERE route_id=$1 ORDER BY id', [routeIds[1]])).rows), reverseBefore);
|
||||
|
||||
// A future import closes old versions on the preceding day, keeping today's data intact.
|
||||
sourceHtml = sourceHtml.replace('05:22', '05:23');
|
||||
const inspected = await service.inspect({ routeIds, sourceUrl, userId: 1 });
|
||||
const tomorrow = new Date(`${validFrom}T12:00:00Z`); tomorrow.setUTCDate(tomorrow.getUTCDate() + 1);
|
||||
const future = await service.preview({ inspectionId: inspected.id, mappings: inspected.mappings, validFrom: tomorrow.toISOString().slice(0, 10), userId: 1 });
|
||||
await service.apply(future.id);
|
||||
const versions = (await pool.query("SELECT to_char(valid_until,'YYYY-MM-DD') AS until FROM schedules WHERE day_type='weekday' AND route_id = $1 ORDER BY id", [routeId])).rows;
|
||||
assert.equal(versions.length, 2);
|
||||
assert.equal(versions[0].until, validFrom);
|
||||
assert.equal(versions[1].until, null);
|
||||
|
||||
// Copying the selected version must not choose a newer future version or overwrite history.
|
||||
await pool.query(`INSERT INTO schedules (route_id,day_type,departure_times,trips,valid_from,valid_until)
|
||||
VALUES ($1,'holiday',ARRAY['04:00'::time],'[]','2020-01-01','2020-01-02')`, [routeId]);
|
||||
const vm = require('node:vm');
|
||||
const moduleStub = { exports: {} };
|
||||
vm.runInNewContext(await fs.readFile(path.join(__dirname, '../src/controllers/schedulesController.js'), 'utf8'), {
|
||||
module: moduleStub, require: name => {
|
||||
if (name.endsWith('/database')) return pool;
|
||||
if (name.endsWith('/redis')) return { del: async () => {} };
|
||||
if (name.endsWith('/logger')) return { info() {}, error() {} };
|
||||
throw new Error(`Unexpected dependency: ${name}`);
|
||||
}
|
||||
});
|
||||
const selectedId = (await pool.query("SELECT id FROM schedules WHERE day_type='weekday' AND route_id = $1 ORDER BY id LIMIT 1", [routeId])).rows[0].id;
|
||||
let responseBody;
|
||||
const response = { status(code) { assert.ok(code < 400); return this; }, json(body) { responseBody = body; } };
|
||||
await moduleStub.exports.copySchedule({ params: { routeId }, body: { fromDayType: 'weekday', toDayType: 'holiday', sourceScheduleId: selectedId } }, response);
|
||||
assert.equal(responseBody.trips.some(t => t.departureTime === '05:22:00'), true);
|
||||
assert.equal(responseBody.trips.some(t => t.departureTime === '05:23:00'), false);
|
||||
assert.equal(Number((await pool.query("SELECT count(*) FROM schedules WHERE day_type='holiday'")).rows[0].count), 2);
|
||||
|
||||
let news = await service.news(true);
|
||||
assert.equal(news.length, 3);
|
||||
await service.reviewNews({ url: news[0].url, contentHash: news[0].content_hash });
|
||||
assert.equal((await service.news(true))[0].reviewed, true);
|
||||
newsHtml = newsHtml.replace('08:50', '08:51');
|
||||
news = await service.news(true);
|
||||
assert.equal(news[0].reviewed, false);
|
||||
assert.equal(news.length, 3);
|
||||
} finally {
|
||||
if (pool) await pool.end();
|
||||
await admin.query(`DROP SCHEMA IF EXISTS ${schema} CASCADE`);
|
||||
await admin.end();
|
||||
}
|
||||
});
|
||||
164
backend/test/scheduleImport.test.js
Normal file
164
backend/test/scheduleImport.test.js
Normal 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'), /противоречит/);
|
||||
});
|
||||
Reference in New Issue
Block a user