Files
enduro-trails/tests/unit/gpx.test.js
claude-bot 25e4476cf7
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
fix(gpx): устранить падение статистики на больших треках, учесть все треки файла
Правки по код-ревью 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>
2026-05-22 06:01:51 +00:00

567 lines
23 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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, '&amp;').replace(/</g, '&lt;'));
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);
});