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

@@ -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;

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

View 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>

View 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>

View 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>

View 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>

View 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>

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

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

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