fix(gpx): устранить падение статистики на больших треках, учесть все треки файла
All checks were successful
CI / lint (push) Successful in 4s
CI / test (push) Successful in 5s
CI / lint (pull_request) Successful in 3s
CI / test (pull_request) Successful in 5s
CI / build (push) Successful in 3s
CI / build (pull_request) Successful in 2s

Правки по код-ревью ET-006 (docs/work-items/ET-006/12-review.md):

- P1-1: trackStats считал min/max высот через Math.min/max.apply — на
  треках в сотни тысяч точек это бросало RangeError и валило загрузку
  файла (нарушение REQ-NF-01). Расчёт переписан на однопроходный
  аккумулятор (makeStatsAccumulator/accumulatePoint/finalizeStats)
  без apply.
- P2-1: статистика и профиль высот учитывали только tracks[0].
  Добавлены aggregateStats() и buildFileProfileSamples() — сводка и
  профиль теперь охватывают все треки файла (REQ-F-09, AC-02).
- P2-2: расчёт статистики на async-пути парсинга вынесен в чанковый
  trackStatsChunked() — соответствие букве ADR-003 §2.
- P3-1: ось и tooltip профиля высот форматируют расстояние через
  formatKm() — согласование с выбором км/мили из ET-005.
- P3-2: childText() переименована в firstTagText() — имя соответствует
  фактическому поведению (поиск по всем потомкам).
- P3-4: убран дублирующийся 'use strict'.

Добавлены регрессионные unit-тесты: большой трек без падения,
эквивалентность trackStatsChunked синхронному trackStats (в т.ч. на
треке длиннее размера чанка), агрегация статистики и профиля по
многотрековому файлу.

Refs: ET-006

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-22 06:01:51 +00:00
parent 19354ed905
commit 25e4476cf7
2 changed files with 271 additions and 62 deletions

View File

@@ -7,7 +7,10 @@
* - unit-gpx-parser (U-01..U-08)
* - unit-gpx-stats (U-10..U-14)
* - unit-gpx-colors (U-20, U-21)
* плюс чистые функции построения GeoJSON и bbox.
* плюс чистые функции построения GeoJSON и bbox, плюс регрессии по
* замечаниям код-ревью ET-006: P1-1 (большие треки не валят расчёт
* статистики), P2-1 (агрегация статистики и профиля по всем трекам
* файла), P2-2 (чанковый расчёт статистики — trackStatsChunked).
*
* Тесты исполняют РЕАЛЬНЫЙ модуль src/web/gpx.js. Браузерный примитив
* `DOMParser` (ADR-003) в Node отсутствует, поэтому подставляется
@@ -398,6 +401,40 @@ test('trackStats: пустой трек — нулевая длина без п
assert.equal(stats.elevGain, null);
});
test('P1-1: trackStats не падает на треке с сотнями тысяч точек высот', () => {
// Регрессия ревью P1-1: Math.min/max.apply на массиве такого размера
// бросал RangeError: Maximum call stack size exceeded → файл не
// загружался (нарушение REQ-NF-01). Однопроходный обход — без apply.
const points = [];
for (let i = 0; i < 500000; i++) {
points.push([37.6 + i * 1e-6, 55.7 + i * 1e-6, 100 + (i % 50)]);
}
let stats;
assert.doesNotThrow(() => { stats = Gpx.trackStats(points); });
assert.equal(stats.eleMin, 100);
assert.equal(stats.eleMax, 149);
assert.ok(stats.distanceKm > 0, 'длина считается на большом треке');
});
test('trackStatsChunked даёт тот же результат, что и синхронный trackStats', async () => {
const points = [
[37.6, 55.70, 100], [37.6, 55.71, 150], [37.6, 55.72, 120],
[37.6, 55.73, 200], [37.6, 55.74, 180],
];
const chunked = await Gpx.trackStatsChunked(points);
assert.deepEqual(chunked, Gpx.trackStats(points));
});
test('trackStatsChunked: расчёт верен на треке длиннее размера чанка', async () => {
// > CHUNK_SIZE (8000) точек — статистика проходит через несколько чанков.
const points = [];
for (let i = 0; i < 20000; i++) {
points.push([37.6 + i * 1e-5, 55.7, 100 + (i % 30)]);
}
const chunked = await Gpx.trackStatsChunked(points);
assert.deepEqual(chunked, Gpx.trackStats(points));
});
// ─── unit-gpx-colors : U-20, U-21 ──────────────────────────────────────────
test('U-20: первый файл получает первый цвет палитры', () => {
@@ -456,6 +493,63 @@ test('fileBounds: файл без точек → null', () => {
assert.equal(Gpx.fileBounds({ tracks: [], waypoints: [] }), null);
});
// ─── Агрегация по файлу: aggregateStats / buildFileProfileSamples (P2-1) ────
test('P2-1: aggregateStats суммирует статистику всех треков файла', () => {
// Ревью P2-1: панель показывает один файл, но файл может содержать
// несколько <trk> — статистика должна охватывать их все, не только [0].
const tracks = [
{ stats: { distanceKm: 10, elevGain: 100, elevLoss: 50, eleMin: 120, eleMax: 300 } },
{ stats: { distanceKm: 5, elevGain: 40, elevLoss: 20, eleMin: 90, eleMax: 250 } },
];
const agg = Gpx.aggregateStats(tracks);
assert.equal(agg.distanceKm, 15);
assert.equal(agg.elevGain, 140);
assert.equal(agg.elevLoss, 70);
assert.equal(agg.eleMin, 90);
assert.equal(agg.eleMax, 300);
});
test('P2-1: aggregateStats — трек без высот не ломает агрегацию', () => {
const tracks = [
{ stats: { distanceKm: 10, elevGain: 100, elevLoss: 50, eleMin: 120, eleMax: 300 } },
{ stats: { distanceKm: 5, elevGain: null, elevLoss: null, eleMin: null, eleMax: null } },
];
const agg = Gpx.aggregateStats(tracks);
assert.equal(agg.distanceKm, 15);
assert.equal(agg.elevGain, 100);
assert.equal(agg.elevLoss, 50);
assert.equal(agg.eleMin, 120);
assert.equal(agg.eleMax, 300);
});
test('P2-1: aggregateStats — все треки без высот → поля высот null', () => {
const tracks = [
{ stats: { distanceKm: 7, elevGain: null, elevLoss: null, eleMin: null, eleMax: null } },
];
const agg = Gpx.aggregateStats(tracks);
assert.equal(agg.distanceKm, 7);
assert.equal(agg.elevGain, null);
assert.equal(agg.elevLoss, null);
assert.equal(agg.eleMin, null);
assert.equal(agg.eleMax, null);
});
test('P2-1: buildFileProfileSamples объединяет высоты всех треков файла', () => {
const t1 = { points: [[37.60, 55.70, 100], [37.61, 55.70, 200]] };
const t2 = { points: [[37.70, 55.80, 300], [37.71, 55.80, 400]] };
t1.stats = Gpx.trackStats(t1.points);
t2.stats = Gpx.trackStats(t2.points);
const samples = Gpx.buildFileProfileSamples({ tracks: [t1, t2] });
// Все 4 точки с высотой попали в профиль — не только из tracks[0].
assert.equal(samples.length, 4);
assert.deepEqual(samples.map((s) => s.e), [100, 200, 300, 400]);
// Расстояние — сквозное: второй трек смещён на длину первого.
assert.equal(samples[0].d, 0);
assert.ok(samples[2].d >= t1.stats.distanceKm - 1e-9);
assert.ok(samples[3].d > samples[2].d);
});
// ─── Контракт модуля ───────────────────────────────────────────────────────
test('модуль публикует window.Gpx и onclick-обработчики', () => {