'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(//g, ''); s = s.replace(//gi, ''); s = s.replace(//g, (m, c) => c.replace(/&/g, '&').replace(/ 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 возвращает документ с корнем ``. */ 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 ? `${p.ele}` : ''; const time = p.time !== undefined ? `` : ''; return `${ele}${time}`; }).join(''); const ns = xmlns ? ` xmlns="${xmlns}"` : ''; return ` ${name}${pts}`; } /** 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) => `T${n}` + `` + ''; const xml = `` + trk(1) + trk(2) + trk(3) + ''; 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 += `` + `Точка ${i}${100 + i}`; } const xml = `${wpts}`; 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 += ``; } const xml = `` + `Маршрут A${rtepts}`; 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(''), /PARSE_ERROR/, ); assert.throws( () => Gpx.parseGpxText('это просто текст, а не XML'), /PARSE_ERROR/, ); }); test('U-07: пустой GPX (нет trk/wpt/rte) — бросает EMPTY', () => { const xml = ``; 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 = ``; await assert.rejects(Gpx.parseGpxAsync(xml), /EMPTY/); }); test('extractGpxModel: трек и waypoints из одного файла', () => { const xml = `` + 'Tr' + '' + 'Кафе'; 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: панель показывает один файл, но файл может содержать // несколько — статистика должна охватывать их все, не только [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); });