fix(gpx): устранить падение статистики на больших треках, учесть все треки файла
All checks were successful
All checks were successful
Правки по код-ревью 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:
@@ -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-обработчики', () => {
|
||||
|
||||
Reference in New Issue
Block a user