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>
567 lines
23 KiB
JavaScript
567 lines
23 KiB
JavaScript
'use strict';
|
||
|
||
/**
|
||
* ET-006 — поведенческие unit-тесты модуля загрузки GPX-треков.
|
||
*
|
||
* Покрывают группы unit-кейсов из docs/work-items/ET-006/04-test-plan.yaml:
|
||
* - unit-gpx-parser (U-01..U-08)
|
||
* - unit-gpx-stats (U-10..U-14)
|
||
* - unit-gpx-colors (U-20, U-21)
|
||
* плюс чистые функции построения GeoJSON и bbox, плюс регрессии по
|
||
* замечаниям код-ревью ET-006: P1-1 (большие треки не валят расчёт
|
||
* статистики), P2-1 (агрегация статистики и профиля по всем трекам
|
||
* файла), P2-2 (чанковый расчёт статистики — trackStatsChunked).
|
||
*
|
||
* Тесты исполняют РЕАЛЬНЫЙ модуль src/web/gpx.js. Браузерный примитив
|
||
* `DOMParser` (ADR-003) в Node отсутствует, поэтому подставляется
|
||
* компактный мок-парсер XML (`MockDOMParser`) — он генерирует DOM-lite
|
||
* узлы с тем подмножеством DOM API, которое использует gpx.js
|
||
* (`getElementsByTagName`, `getAttribute`, `textContent`). Это позволяет
|
||
* проверить настоящую GPX-семантику конвертации, не таща в проект jsdom.
|
||
*
|
||
* Две поправки к числовым ПРИМЕРАМ в 04-test-plan.yaml (сам ТЗ §5
|
||
* корректен, расходятся лишь оценочные числа аналитика в примерах):
|
||
* - U-10: для точек 0.1°×0.1° каноническая формула Haversine (та же,
|
||
* что в app.js) даёт ≈25.5 км, а не 28.3 км. Тест проверяет 25.5.
|
||
* - U-11: для ele [100,150,120,200,180] сброс высоты = 30+20 = 50 м
|
||
* (как и записано в самой расшифровке кейса), а не 70 м.
|
||
*
|
||
* Запуск: `node --test tests/unit/gpx.test.js`
|
||
* (в CI оборачивается pytest-тестом tests/unit/test_gpx_upload.py).
|
||
*/
|
||
|
||
const test = require('node:test');
|
||
const assert = require('node:assert/strict');
|
||
const path = require('node:path');
|
||
|
||
const GPX_PATH = path.join(__dirname, '..', '..', 'src', 'web', 'gpx.js');
|
||
|
||
// ─── Мини-XML-парсер: DOM-lite для подмены браузерного DOMParser ────────────
|
||
|
||
/** Декодирует базовые XML-сущности. */
|
||
function decodeEntities(s) {
|
||
return String(s).replace(/&(#x?[0-9a-fA-F]+|[a-zA-Z]+);/g, (m, ent) => {
|
||
if (ent[0] === '#') {
|
||
const hex = ent[1] === 'x' || ent[1] === 'X';
|
||
const code = hex ? parseInt(ent.slice(2), 16) : parseInt(ent.slice(1), 10);
|
||
return isNaN(code) ? m : String.fromCodePoint(code);
|
||
}
|
||
const named = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'" };
|
||
return ent in named ? named[ent] : m;
|
||
});
|
||
}
|
||
|
||
/** Локальное имя тега (без префикса namespace). */
|
||
function localName(tag) {
|
||
const i = tag.indexOf(':');
|
||
return i === -1 ? tag : tag.slice(i + 1);
|
||
}
|
||
|
||
/** DOM-lite элемент. */
|
||
class El {
|
||
constructor(tagName) {
|
||
this.tagName = tagName;
|
||
this.nodeName = tagName;
|
||
this._attrs = {};
|
||
this.childNodes = [];
|
||
}
|
||
|
||
getAttribute(name) {
|
||
return name in this._attrs ? this._attrs[name] : null;
|
||
}
|
||
|
||
getElementsByTagName(name) {
|
||
const out = [];
|
||
const walk = (node) => {
|
||
node.childNodes.forEach((c) => {
|
||
if (c instanceof El) {
|
||
if (c.tagName === name || localName(c.tagName) === name) out.push(c);
|
||
walk(c);
|
||
}
|
||
});
|
||
};
|
||
walk(this);
|
||
return out;
|
||
}
|
||
|
||
get textContent() {
|
||
return this.childNodes
|
||
.map((c) => (c instanceof El ? c.textContent : c.text))
|
||
.join('');
|
||
}
|
||
}
|
||
|
||
/** DOM-lite документ. */
|
||
class Doc {
|
||
constructor(root) {
|
||
this.documentElement = root;
|
||
}
|
||
|
||
getElementsByTagName(name) {
|
||
const root = this.documentElement;
|
||
if (!root) return [];
|
||
const out = [];
|
||
if (root.tagName === name || localName(root.tagName) === name) out.push(root);
|
||
return out.concat(root.getElementsByTagName(name));
|
||
}
|
||
}
|
||
|
||
/** Разбирает строку XML в DOM-lite дерево. Бросает Error на невалидном XML. */
|
||
function parseXml(input) {
|
||
let s = String(input).replace(/^/, '');
|
||
s = s.replace(/<\?[\s\S]*?\?>/g, '');
|
||
s = s.replace(/<!--[\s\S]*?-->/g, '');
|
||
s = s.replace(/<!DOCTYPE[\s\S]*?>/gi, '');
|
||
s = s.replace(/<!\[CDATA\[([\s\S]*?)\]\]>/g,
|
||
(m, c) => c.replace(/&/g, '&').replace(/</g, '<'));
|
||
|
||
const stack = [];
|
||
let root = null;
|
||
let i = 0;
|
||
|
||
while (i < s.length) {
|
||
const lt = s.indexOf('<', i);
|
||
if (lt === -1) break;
|
||
if (lt > i) {
|
||
const text = s.slice(i, lt);
|
||
if (stack.length && text.trim() !== '') {
|
||
stack[stack.length - 1].childNodes.push({ text: decodeEntities(text) });
|
||
}
|
||
}
|
||
const gt = s.indexOf('>', lt);
|
||
if (gt === -1) throw new Error('malformed: no closing >');
|
||
let tag = s.slice(lt + 1, gt).trim();
|
||
i = gt + 1;
|
||
|
||
if (tag[0] === '/') {
|
||
const cname = tag.slice(1).trim();
|
||
if (!stack.length || stack[stack.length - 1].tagName !== cname) {
|
||
throw new Error('malformed: unbalanced tag ' + cname);
|
||
}
|
||
stack.pop();
|
||
continue;
|
||
}
|
||
|
||
let selfClose = false;
|
||
if (tag[tag.length - 1] === '/') {
|
||
selfClose = true;
|
||
tag = tag.slice(0, -1).trim();
|
||
}
|
||
const m = tag.match(/^(\S+)([\s\S]*)$/);
|
||
if (!m) throw new Error('malformed: empty tag');
|
||
const el = new El(m[1]);
|
||
const attrRe = /([^\s=]+)\s*=\s*"([^"]*)"|([^\s=]+)\s*=\s*'([^']*)'/g;
|
||
let am;
|
||
while ((am = attrRe.exec(m[2]))) {
|
||
if (am[1] !== undefined) el._attrs[am[1]] = decodeEntities(am[2]);
|
||
else el._attrs[am[3]] = decodeEntities(am[4]);
|
||
}
|
||
if (stack.length) stack[stack.length - 1].childNodes.push(el);
|
||
else if (!root) root = el;
|
||
else throw new Error('malformed: multiple roots');
|
||
if (!selfClose) stack.push(el);
|
||
}
|
||
|
||
if (stack.length) throw new Error('malformed: unclosed tag');
|
||
if (!root) throw new Error('malformed: no root element');
|
||
return new Doc(root);
|
||
}
|
||
|
||
/**
|
||
* Мок браузерного DOMParser. Как и настоящий — не бросает исключение,
|
||
* а на невалидном XML возвращает документ с корнем `<parsererror>`.
|
||
*/
|
||
class MockDOMParser {
|
||
parseFromString(str) {
|
||
try {
|
||
return parseXml(str);
|
||
} catch (e) {
|
||
const err = new El('parsererror');
|
||
err.childNodes.push({ text: String(e.message) });
|
||
return new Doc(err);
|
||
}
|
||
}
|
||
}
|
||
|
||
// ─── Загрузка модуля под тестом ────────────────────────────────────────────
|
||
|
||
global.DOMParser = MockDOMParser;
|
||
delete require.cache[require.resolve(GPX_PATH)];
|
||
const Gpx = require(GPX_PATH);
|
||
|
||
// ─── Генераторы тестовых GPX ───────────────────────────────────────────────
|
||
|
||
const NS = 'http://www.topografix.com/GPX/1/1';
|
||
|
||
/** Собирает GPX 1.1 с одним треком из списка точек {lat, lon, ele?, time?}. */
|
||
function gpxWithTrack(points, { name = 'Тест', xmlns = NS } = {}) {
|
||
const pts = points.map((p) => {
|
||
const ele = p.ele !== undefined ? `<ele>${p.ele}</ele>` : '';
|
||
const time = p.time !== undefined ? `<time>${p.time}</time>` : '';
|
||
return `<trkpt lat="${p.lat}" lon="${p.lon}">${ele}${time}</trkpt>`;
|
||
}).join('');
|
||
const ns = xmlns ? ` xmlns="${xmlns}"` : '';
|
||
return `<?xml version="1.0" encoding="UTF-8"?>
|
||
<gpx version="1.1"${ns}><trk><name>${name}</name><trkseg>${pts}</trkseg></trk></gpx>`;
|
||
}
|
||
|
||
/** 10 точек с ele и time — тест U-01. */
|
||
function tenPoints() {
|
||
const pts = [];
|
||
for (let i = 0; i < 10; i++) {
|
||
pts.push({
|
||
lat: 55.70 + i * 0.001,
|
||
lon: 37.60 + i * 0.001,
|
||
ele: 150 + i * 5,
|
||
time: `2026-01-01T08:0${i}:00Z`,
|
||
});
|
||
}
|
||
return pts;
|
||
}
|
||
|
||
// ─── unit-gpx-parser : U-01..U-08 ──────────────────────────────────────────
|
||
|
||
test('U-01: парсинг валидного GPX 1.1 с одним треком (10 точек)', () => {
|
||
const model = Gpx.parseGpxText(gpxWithTrack(tenPoints()));
|
||
assert.equal(model.tracks.length, 1);
|
||
assert.equal(model.tracks[0].points.length, 10);
|
||
// [lon, lat, ele, time]
|
||
const first = model.tracks[0].points[0];
|
||
assert.equal(first[0], 37.60);
|
||
assert.equal(first[1], 55.70);
|
||
assert.equal(first[2], 150);
|
||
assert.equal(first[3], '2026-01-01T08:00:00Z');
|
||
assert.equal(model.tracks[0].points[9][2], 195);
|
||
});
|
||
|
||
test('U-02: парсинг GPX с несколькими треками', () => {
|
||
const trk = (n) => `<trk><name>T${n}</name><trkseg>` +
|
||
`<trkpt lat="55.7" lon="37.6"/><trkpt lat="55.8" lon="37.7"/>` +
|
||
'</trkseg></trk>';
|
||
const xml = `<?xml version="1.0"?><gpx version="1.1" xmlns="${NS}">` +
|
||
trk(1) + trk(2) + trk(3) + '</gpx>';
|
||
const model = Gpx.parseGpxText(xml);
|
||
assert.equal(model.tracks.length, 3);
|
||
assert.deepEqual(model.tracks.map((t) => t.name), ['T1', 'T2', 'T3']);
|
||
});
|
||
|
||
test('U-03: парсинг waypoints (5 шт. с именами и координатами)', () => {
|
||
let wpts = '';
|
||
for (let i = 0; i < 5; i++) {
|
||
wpts += `<wpt lat="${55.7 + i * 0.01}" lon="${37.6 + i * 0.01}">` +
|
||
`<name>Точка ${i}</name><ele>${100 + i}</ele></wpt>`;
|
||
}
|
||
const xml = `<?xml version="1.0"?><gpx version="1.1" xmlns="${NS}">${wpts}</gpx>`;
|
||
const model = Gpx.parseGpxText(xml);
|
||
assert.equal(model.waypoints.length, 5);
|
||
assert.equal(model.waypoints[0].name, 'Точка 0');
|
||
assert.equal(model.waypoints[4].name, 'Точка 4');
|
||
assert.equal(model.waypoints[2].ele, 102);
|
||
assert.equal(model.waypoints[0].lon, 37.6);
|
||
});
|
||
|
||
test('U-04: парсинг route (rte) — трактуется как трек', () => {
|
||
let rtepts = '';
|
||
for (let i = 0; i < 20; i++) {
|
||
rtepts += `<rtept lat="${55.7 + i * 0.001}" lon="${37.6 + i * 0.001}"/>`;
|
||
}
|
||
const xml = `<?xml version="1.0"?><gpx version="1.1" xmlns="${NS}">` +
|
||
`<rte><name>Маршрут A</name>${rtepts}</rte></gpx>`;
|
||
const model = Gpx.parseGpxText(xml);
|
||
assert.equal(model.tracks.length, 1);
|
||
assert.equal(model.tracks[0].points.length, 20);
|
||
assert.equal(model.tracks[0].name, 'Маршрут A');
|
||
});
|
||
|
||
test('U-05: GPX без данных высот — ele=null, stats.elevGain=null', () => {
|
||
const pts = [
|
||
{ lat: 55.70, lon: 37.60 },
|
||
{ lat: 55.71, lon: 37.61 },
|
||
{ lat: 55.72, lon: 37.62 },
|
||
];
|
||
const model = Gpx.parseGpxText(gpxWithTrack(pts));
|
||
const track = model.tracks[0];
|
||
assert.equal(track.points[0][2], null);
|
||
assert.equal(track.stats.elevGain, null);
|
||
assert.equal(track.stats.elevLoss, null);
|
||
assert.equal(track.stats.eleMin, null);
|
||
assert.equal(track.stats.eleMax, null);
|
||
assert.ok(track.stats.distanceKm > 0, 'длина считается и без высот');
|
||
});
|
||
|
||
test('U-06: невалидный XML — parseGpxText бросает PARSE_ERROR', () => {
|
||
assert.throws(
|
||
() => Gpx.parseGpxText('<gpx><trk><trkseg></trk></gpx>'),
|
||
/PARSE_ERROR/,
|
||
);
|
||
assert.throws(
|
||
() => Gpx.parseGpxText('это просто текст, а не XML'),
|
||
/PARSE_ERROR/,
|
||
);
|
||
});
|
||
|
||
test('U-07: пустой GPX (нет trk/wpt/rte) — бросает EMPTY', () => {
|
||
const xml = `<?xml version="1.0"?><gpx version="1.1" xmlns="${NS}"></gpx>`;
|
||
assert.throws(() => Gpx.parseGpxText(xml), /EMPTY/);
|
||
});
|
||
|
||
test('U-08: GPX без xmlns парсится корректно (fallback без namespace)', () => {
|
||
const model = Gpx.parseGpxText(gpxWithTrack(tenPoints(), { xmlns: null }));
|
||
assert.equal(model.tracks.length, 1);
|
||
assert.equal(model.tracks[0].points.length, 10);
|
||
});
|
||
|
||
test('parseGpxAsync даёт тот же результат, что и синхронный парсер', async () => {
|
||
const xml = gpxWithTrack(tenPoints());
|
||
const sync = Gpx.parseGpxText(xml);
|
||
const async = await Gpx.parseGpxAsync(xml);
|
||
assert.deepEqual(async, sync);
|
||
});
|
||
|
||
test('parseGpxAsync отклоняется с EMPTY на пустом GPX', async () => {
|
||
const xml = `<?xml version="1.0"?><gpx version="1.1" xmlns="${NS}"></gpx>`;
|
||
await assert.rejects(Gpx.parseGpxAsync(xml), /EMPTY/);
|
||
});
|
||
|
||
test('extractGpxModel: трек и waypoints из одного файла', () => {
|
||
const xml = `<?xml version="1.0"?><gpx version="1.1" xmlns="${NS}">` +
|
||
'<trk><name>Tr</name><trkseg><trkpt lat="55.7" lon="37.6"/>' +
|
||
'<trkpt lat="55.8" lon="37.7"/></trkseg></trk>' +
|
||
'<wpt lat="55.75" lon="37.65"><name>Кафе</name></wpt></gpx>';
|
||
const doc = new MockDOMParser().parseFromString(xml);
|
||
const model = Gpx.extractGpxModel(doc);
|
||
assert.equal(model.tracks.length, 1);
|
||
assert.equal(model.waypoints.length, 1);
|
||
assert.equal(model.waypoints[0].name, 'Кафе');
|
||
});
|
||
|
||
// ─── unit-gpx-stats : U-10..U-14 ───────────────────────────────────────────
|
||
|
||
test('U-10: длина трека по Haversine (каноническая формула проекта)', () => {
|
||
const points = [
|
||
[37.6, 55.7], [37.7, 55.8], [37.8, 55.9],
|
||
];
|
||
const stats = Gpx.trackStats(points);
|
||
// Каноническая Haversine (как в app.js haversineKm) для шага 0.1°×0.1°
|
||
// даёт ≈25.5 км. Значение «28.3 км» в 04-test-plan.yaml — неточная
|
||
// оценка аналитика; реализация следует ТЗ §5.1 (формула Haversine).
|
||
assert.ok(
|
||
Math.abs(stats.distanceKm - 25.5) < 0.5,
|
||
`ожидали ≈25.5 км, получили ${stats.distanceKm}`,
|
||
);
|
||
});
|
||
|
||
test('U-11: набор и сброс высоты по дельтам ele', () => {
|
||
const points = [
|
||
[37.6, 55.7, 100], [37.6, 55.71, 150], [37.6, 55.72, 120],
|
||
[37.6, 55.73, 200], [37.6, 55.74, 180],
|
||
];
|
||
const stats = Gpx.trackStats(points);
|
||
// Дельты: +50, -30, +80, -20 → набор 130, сброс 50.
|
||
assert.equal(stats.elevGain, 130);
|
||
assert.equal(stats.elevLoss, 50);
|
||
});
|
||
|
||
test('U-12: фильтрация шума высот — дельты < 2 м игнорируются', () => {
|
||
const points = [
|
||
[37.6, 55.70, 100], [37.6, 55.71, 101], [37.6, 55.72, 100],
|
||
[37.6, 55.73, 101], [37.6, 55.74, 150],
|
||
];
|
||
const stats = Gpx.trackStats(points);
|
||
// Колебания ±1 м не сдвигают опорную высоту → набор = 100→150 = 50 м.
|
||
assert.equal(stats.elevGain, 50);
|
||
assert.equal(stats.elevLoss, 0);
|
||
});
|
||
|
||
test('U-13: минимальная и максимальная высота', () => {
|
||
const points = [
|
||
[37.6, 55.70, 100], [37.6, 55.71, 250], [37.6, 55.72, 80],
|
||
[37.6, 55.73, 300], [37.6, 55.74, 150],
|
||
];
|
||
const stats = Gpx.trackStats(points);
|
||
assert.equal(stats.eleMin, 80);
|
||
assert.equal(stats.eleMax, 300);
|
||
});
|
||
|
||
test('U-14: статистика без данных высот — длина есть, высоты null', () => {
|
||
const points = [
|
||
[37.6, 55.70], [37.6, 55.71], [37.6, 55.72],
|
||
];
|
||
const stats = Gpx.trackStats(points);
|
||
assert.ok(stats.distanceKm > 0);
|
||
assert.equal(stats.elevGain, null);
|
||
assert.equal(stats.elevLoss, null);
|
||
assert.equal(stats.eleMin, null);
|
||
assert.equal(stats.eleMax, null);
|
||
});
|
||
|
||
test('trackStats: пустой трек — нулевая длина без падения', () => {
|
||
const stats = Gpx.trackStats([]);
|
||
assert.equal(stats.distanceKm, 0);
|
||
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: первый файл получает первый цвет палитры', () => {
|
||
assert.equal(Gpx.colorForIndex(0), '#e6194b');
|
||
});
|
||
|
||
test('U-21: девятый файл получает первый цвет (цикл 8 % 8 = 0)', () => {
|
||
assert.equal(Gpx.colorForIndex(8), '#e6194b');
|
||
assert.equal(Gpx.colorForIndex(8), Gpx.colorForIndex(0));
|
||
});
|
||
|
||
test('палитра содержит ровно 8 цветов и отличается от цветов роутинга', () => {
|
||
assert.equal(Gpx.PALETTE.length, 8);
|
||
// Цвета роутинга из app.js — не должны пересекаться (TRZ REQ-F-04).
|
||
const routeColors = ['#0066ff', '#00aa44', '#9933cc', '#ff8800', '#888888'];
|
||
Gpx.PALETTE.forEach((c) => {
|
||
assert.ok(!routeColors.includes(c), `${c} совпадает с цветом роутинга`);
|
||
});
|
||
});
|
||
|
||
test('colorForIndex циклически проходит всю палитру', () => {
|
||
for (let i = 0; i < 24; i++) {
|
||
assert.equal(Gpx.colorForIndex(i), Gpx.PALETTE[i % 8]);
|
||
}
|
||
});
|
||
|
||
// ─── Чистые функции: GeoJSON и bbox ────────────────────────────────────────
|
||
|
||
test('tracksToGeoJSON: трек → LineString-фича с [lon,lat]-координатами', () => {
|
||
const tracks = [{ points: [[37.6, 55.7, 100], [37.7, 55.8, 110]] }];
|
||
const fc = Gpx.tracksToGeoJSON(tracks);
|
||
assert.equal(fc.type, 'FeatureCollection');
|
||
assert.equal(fc.features.length, 1);
|
||
assert.equal(fc.features[0].geometry.type, 'LineString');
|
||
assert.deepEqual(fc.features[0].geometry.coordinates, [[37.6, 55.7], [37.7, 55.8]]);
|
||
});
|
||
|
||
test('waypointsToGeoJSON: waypoint → Point-фича с именем в properties', () => {
|
||
const fc = Gpx.waypointsToGeoJSON([{ lon: 37.6, lat: 55.7, name: 'Брод' }]);
|
||
assert.equal(fc.features.length, 1);
|
||
assert.equal(fc.features[0].geometry.type, 'Point');
|
||
assert.deepEqual(fc.features[0].geometry.coordinates, [37.6, 55.7]);
|
||
assert.equal(fc.features[0].properties.name, 'Брод');
|
||
});
|
||
|
||
test('fileBounds: bbox охватывает все точки треков и waypoints', () => {
|
||
const file = {
|
||
tracks: [{ points: [[37.5, 55.6], [37.9, 55.9]] }],
|
||
waypoints: [{ lon: 37.4, lat: 56.0 }],
|
||
};
|
||
const b = Gpx.fileBounds(file);
|
||
assert.deepEqual(b, [[37.4, 55.6], [37.9, 56.0]]);
|
||
});
|
||
|
||
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-обработчики', () => {
|
||
assert.equal(global.Gpx, Gpx);
|
||
assert.equal(typeof global.onGpxFileSelected, 'function');
|
||
assert.equal(typeof global.toggleGpxSheet, 'function');
|
||
assert.equal(typeof global.selectGpxTrack, 'function');
|
||
assert.equal(typeof global.removeGpxTrack, 'function');
|
||
assert.equal(typeof global.rebuildGpxOverlays, 'function');
|
||
});
|
||
|
||
test('MAX_FILE_BYTES равен 50 МБ (TRZ REQ-F-03)', () => {
|
||
assert.equal(Gpx.MAX_FILE_BYTES, 50 * 1024 * 1024);
|
||
});
|