From 21f511a779c7e2d56c9450500ef5b5ae7456a269 Mon Sep 17 00:00:00 2001 From: Tomek Zebrowski Date: Thu, 22 Jan 2026 21:09:47 +0100 Subject: [PATCH 1/7] feat: add searchable list to xy scatter view --- src/mathchannels.js | 255 ++++++++++++++++++---------------- src/style.css | 61 ++++++++ src/xyanalysis.js | 330 +++++++++++++++++++++++++++++++++++--------- 3 files changed, 459 insertions(+), 187 deletions(-) diff --git a/src/mathchannels.js b/src/mathchannels.js index 4b01219..525154a 100644 --- a/src/mathchannels.js +++ b/src/mathchannels.js @@ -11,6 +11,48 @@ class MathChannels { #getDefinitions() { return [ + { + id: 'est_power_kgh', + name: 'Estimated Power (HP) [Source: kg/h]', + description: + 'Converts kg/h to g/s, then estimates HP. (MAF / 3.6 * Factor)', + inputs: [ + { name: 'maf', label: 'Air Mass Flow (kg/h)' }, + { + name: 'factor', + label: 'Factor (Diesel ~1.35, Petrol ~1.25)', + isConstant: true, + defaultValue: 1.35, + }, + ], + formula: (values) => (values[0] / 3.6) * values[1], + }, + { + id: 'est_power_gs', + name: 'Estimated Power (HP) [Source: g/s]', + description: 'Direct g/s calculation. (MAF * Factor)', + inputs: [ + { name: 'maf', label: 'Air Mass Flow (g/s)' }, + { + name: 'factor', + label: 'Factor (Diesel ~1.35, Petrol ~1.25)', + isConstant: true, + defaultValue: 1.35, + }, + ], + formula: (values) => values[0] * values[1], + }, + { + id: 'power_from_torque', + name: 'Calculated Power (HP) [Source: Torque]', + description: + 'Calculates HP from Torque (Nm) and RPM. (Torque * RPM / 7127)', + inputs: [ + { name: 'torque', label: 'Torque (Nm)' }, + { name: 'rpm', label: 'Engine RPM' }, + ], + formula: (values) => (values[0] * values[1]) / 7127, + }, { id: 'acceleration', name: 'Acceleration (m/s²) [0-100 Logic]', @@ -37,13 +79,9 @@ class MathChannels { if (dt <= 0) continue; const dv = (p2.y - p1.y) / 3.6; - const accel = dv / dt; - result.push({ - x: p2.x, - y: accel, - }); + result.push({ x: p2.x, y: accel }); } return result; }, @@ -76,56 +114,11 @@ class MathChannels { } } - smoothed.push({ - x: sourceData[i].x, - y: sum / count, - }); + smoothed.push({ x: sourceData[i].x, y: sum / count }); } return smoothed; }, }, - { - id: 'est_power_kgh', - name: 'Estimated Power (HP) [Source: kg/h]', - description: - 'Converts kg/h to g/s, then estimates HP. (MAF / 3.6 * Factor)', - inputs: [ - { name: 'maf', label: 'Air Mass Flow (kg/h)' }, - { - name: 'factor', - label: 'Factor (Diesel ~1.35, Petrol ~1.25)', - isConstant: true, - defaultValue: 1.35, - }, - ], - formula: (values) => (values[0] / 3.6) * values[1], - }, - { - id: 'est_power_gs', - name: 'Estimated Power (HP) [Source: g/s]', - description: 'Direct g/s calculation. (MAF * Factor)', - inputs: [ - { name: 'maf', label: 'Air Mass Flow (g/s)' }, - { - name: 'factor', - label: 'Factor (Diesel ~1.35, Petrol ~1.25)', - isConstant: true, - defaultValue: 1.35, - }, - ], - formula: (values) => values[0] * values[1], - }, - { - id: 'power_from_torque', - name: 'Calculated Power (HP) [Source: Torque]', - description: - 'Calculates HP from Torque (Nm) and RPM. (Torque * RPM / 7127)', - inputs: [ - { name: 'torque', label: 'Torque (Nm)' }, - { name: 'rpm', label: 'Engine RPM' }, - ], - formula: (values) => (values[0] * values[1]) / 7127, - }, { id: 'boost', name: 'Boost Pressure (Bar)', @@ -214,7 +207,6 @@ class MathChannels { throw new Error(`Signal '${signalName}' not found in file.`); sourceSignals.push({ isConstant: false, data: signalData }); - if (!masterTimeBase) masterTimeBase = signalData; } }); @@ -223,9 +215,6 @@ class MathChannels { throw new Error('At least one input must be a signal.'); const resultData = []; - let min = Infinity; - let max = -Infinity; - for (let i = 0; i < masterTimeBase.length; i++) { const currentTime = masterTimeBase[i].x; const currentValues = []; @@ -240,37 +229,17 @@ class MathChannels { }); const calculatedY = definition.formula(currentValues); - - if (calculatedY < min) min = calculatedY; - if (calculatedY > max) max = calculatedY; - - resultData.push({ - x: currentTime, - y: calculatedY, - }); - } - - const finalName = newChannelName || `Math: ${definition.name}`; - - file.signals[finalName] = resultData; - - if (!file.metadata) file.metadata = {}; - file.metadata[finalName] = { - min: min, - max: max, - unit: 'Math', - }; - - if (!file.availableSignals.includes(finalName)) { - file.availableSignals.push(finalName); - file.availableSignals.sort(); + resultData.push({ x: currentTime, y: calculatedY }); } - return finalName; + return this.#finalizeChannel( + file, + resultData, + newChannelName || `Math: ${definition.name}` + ); } #executeCustomProcess(file, definition, inputMapping, newChannelName) { - // Extract inputs strictly for custom processor const signals = []; const constants = []; @@ -288,14 +257,9 @@ class MathChannels { } }); - // For smoothing, we assume 1 signal input. - // Generalizing this would require more complex mapping, but for now: if (signals.length === 0) throw new Error('Custom process requires at least one signal.'); - - // Run the custom processor logic defined in #getDefinitions const resultData = definition.customProcess(signals[0], constants); - return this.#finalizeChannel( file, resultData, @@ -325,32 +289,25 @@ class MathChannels { file.availableSignals.push(finalName); file.availableSignals.sort(); } - return finalName; } #interpolate(data, targetTime) { if (!data || data.length === 0) return 0; - if (targetTime <= data[0].x) return parseFloat(data[0].y); if (targetTime >= data[data.length - 1].x) return parseFloat(data[data.length - 1].y); let left = 0; let right = data.length - 1; - while (left <= right) { const mid = Math.floor((left + right) / 2); - if (data[mid].x < targetTime) { - left = mid + 1; - } else { - right = mid - 1; - } + if (data[mid].x < targetTime) left = mid + 1; + else right = mid - 1; } const p1 = data[left - 1]; const p2 = data[left]; - if (!p1) return parseFloat(p2.y); if (!p2) return parseFloat(p1.y); @@ -358,11 +315,9 @@ class MathChannels { const t2 = p2.x; const y1 = parseFloat(p1.y); const y2 = parseFloat(p2.y); - if (t2 === t1) return y1; - const fraction = (targetTime - t1) / (t2 - t1); - return y1 + (y2 - y1) * fraction; + return y1 + (y2 - y1) * ((targetTime - t1) / (t2 - t1)); } #openModal() { @@ -370,7 +325,6 @@ class MathChannels { alert('Please load a log file first.'); return; } - const modal = document.getElementById('mathModal'); if (modal) modal.style.display = 'flex'; @@ -407,37 +361,97 @@ class MathChannels { return; } const file = AppState.files[0]; - const signalOptions = file.availableSignals - .map((s) => ``) - .join(''); definition.inputs.forEach((input, idx) => { const wrapper = document.createElement('div'); - wrapper.style.marginBottom = '10px'; + wrapper.style.marginBottom = '15px'; + + wrapper.innerHTML = ``; - let inputHtml = ''; if (input.isConstant) { - inputHtml = ``; + const inputEl = document.createElement('input'); + inputEl.type = 'number'; + inputEl.id = `math-input-${idx}`; + inputEl.value = input.defaultValue; + inputEl.className = 'template-select'; + inputEl.style.width = '100%'; + wrapper.appendChild(inputEl); } else { - inputHtml = ``; + const searchableSelect = this.#createSearchableSelect( + idx, + file.availableSignals, + input.name + ); + wrapper.appendChild(searchableSelect); } - - wrapper.innerHTML = ` - - ${inputHtml} - `; container.appendChild(wrapper); + }); + } - if (!input.isConstant) { - const sel = wrapper.querySelector('select'); - for (let opt of sel.options) { - if (opt.value.toLowerCase().includes(input.name.toLowerCase())) { - sel.value = opt.value; - break; - } - } + #createSearchableSelect(idx, signals, inputFilterName) { + const wrapper = document.createElement('div'); + wrapper.className = 'searchable-select-wrapper'; + + const input = document.createElement('input'); + input.type = 'text'; + input.id = `math-input-${idx}`; + input.className = 'searchable-input'; + input.placeholder = 'Search or Select Signal...'; + input.autocomplete = 'off'; + + const resultsList = document.createElement('div'); + resultsList.className = 'search-results-list'; + + const defaultSignal = signals.find((s) => + s.toLowerCase().includes(inputFilterName.toLowerCase()) + ); + if (defaultSignal) input.value = defaultSignal; + + const renderOptions = (filterText = '') => { + resultsList.innerHTML = ''; + const lowerFilter = filterText.toLowerCase(); + + const filtered = signals.filter((s) => + s.toLowerCase().includes(lowerFilter) + ); + + if (filtered.length === 0) { + resultsList.innerHTML = + '
No signals found
'; + } else { + filtered.forEach((sig) => { + const opt = document.createElement('div'); + opt.className = 'search-option'; + opt.textContent = sig; + opt.onclick = () => { + input.value = sig; + resultsList.style.display = 'none'; + }; + resultsList.appendChild(opt); + }); + } + }; + + input.addEventListener('focus', () => { + renderOptions(input.value); + resultsList.style.display = 'block'; + }); + + input.addEventListener('input', (e) => { + renderOptions(e.target.value); + resultsList.style.display = 'block'; + }); + + document.addEventListener('click', (e) => { + if (!wrapper.contains(e.target)) { + resultsList.style.display = 'none'; } }); + + wrapper.appendChild(input); + wrapper.appendChild(resultsList); + + return wrapper; } #executeCreation() { @@ -464,12 +478,9 @@ class MathChannels { inputMapping, newName ); - this.#closeModal(); - if (typeof UI.renderSignalList === 'function') { UI.renderSignalList(); - setTimeout(() => { const checkbox = document.querySelector( `input[data-key="${createdName}"]` diff --git a/src/style.css b/src/style.css index 07d391d..f0e0b2b 100644 --- a/src/style.css +++ b/src/style.css @@ -2472,3 +2472,64 @@ input[type='number'] { .signal-separator::after { margin-left: 0.5em; } + +.searchable-select-wrapper { + position: relative; + width: 100%; +} + +.searchable-input { + width: 100%; + box-sizing: border-box; /* Ważne, aby padding nie psuł szerokości */ + padding: 10px; + border-radius: var(--btn-radius); + border: 1px solid var(--border-color); + background-color: #fff; /* Jasne tło */ + color: var(--text-main); +} + +body.pref-theme-dark .searchable-input { + background-color: #2b2b2b; + color: #e0e0e0; + border-color: #444; +} + +.search-results-list { + position: absolute; + top: 100%; + left: 0; + right: 0; + background-color: #fff; + border: 1px solid var(--border-color); + border-top: none; + max-height: 200px; + overflow-y: auto; + z-index: 1000; + border-radius: 0 0 var(--btn-radius) var(--btn-radius); + box-shadow: 0 4px 6px rgb(0 0 0 / 15%); + display: none; +} + +body.pref-theme-dark .search-results-list { + background-color: #2b2b2b; + border-color: #444; +} + +.search-option { + padding: 8px 10px; + cursor: pointer; + font-size: 0.9em; + border-bottom: 1px solid var(--border-color); +} + +.search-option:last-child { + border-bottom: none; +} + +.search-option:hover { + background-color: #f0f0f0; +} + +body.pref-theme-dark .search-option:hover { + background-color: #3a3a3a; +} diff --git a/src/xyanalysis.js b/src/xyanalysis.js index 01df523..2f202a5 100644 --- a/src/xyanalysis.js +++ b/src/xyanalysis.js @@ -45,7 +45,7 @@ export const XYAnalysis = { openXYModal() { const modal = document.getElementById('xyModal'); - modal.style.display = 'flex'; + if (modal) modal.style.display = 'flex'; const splitView = document.getElementById('xySplitView'); const timelineView = document.getElementById('xyTimelineView'); @@ -59,41 +59,68 @@ export const XYAnalysis = { }, closeXYModal() { - document.getElementById('xyModal').style.display = 'none'; + const modal = document.getElementById('xyModal'); + if (modal) modal.style.display = 'none'; }, populateGlobalFileSelector() { - const fileSelect = document.getElementById('xyGlobalFile'); - fileSelect.innerHTML = AppState.files - .map((f, i) => ``) - .join(''); - this.onFileChange(); + const fileNames = AppState.files.map((f) => f.name); + + this.createSearchableSelect( + 'xyGlobalFile', + fileNames, + fileNames[this.currentFileIndex || 0] || '', + (selectedName) => { + const idx = AppState.files.findIndex((f) => f.name === selectedName); + this.currentFileIndex = idx; + this.onFileChange(); + } + ); + + if (AppState.files.length > 0 && this.currentFileIndex === undefined) { + this.currentFileIndex = 0; + this.onFileChange(); + } }, onFileChange() { - const fileIdx = document.getElementById('xyGlobalFile').value; + const fileIdx = + this.currentFileIndex !== undefined ? this.currentFileIndex : 0; const file = AppState.files[fileIdx]; if (!file) return; - const options = file.availableSignals - .sort() - .map((s) => ``) - .join(''); + const signals = file.availableSignals.sort(); ['0', '1'].forEach((panelIdx) => { - document.getElementById(`xyX-${panelIdx}`).innerHTML = options; - document.getElementById(`xyY-${panelIdx}`).innerHTML = options; - document.getElementById(`xyZ-${panelIdx}`).innerHTML = options; - - if (panelIdx === '0') { - this.setSelectValue(`xyX-0`, 'Engine Rpm'); - this.setSelectValue(`xyY-0`, 'Intake Manifold Pressure'); - this.setSelectValue(`xyZ-0`, 'Air Mass'); - } else { - this.setSelectValue(`xyX-1`, 'Engine Rpm'); - this.setSelectValue(`xyY-1`, 'Air Mass Flow Measured'); - this.setSelectValue(`xyZ-1`, 'Intake Manifold Pressure'); - } + let defX = 'Engine Rpm'; + let defY = + panelIdx === '0' + ? 'Intake Manifold Pressure' + : 'Air Mass Flow Measured'; + let defZ = panelIdx === '0' ? 'Air Mass' : 'Intake Manifold Pressure'; + + const matchSignal = (search) => + signals.find((s) => s.toLowerCase().includes(search.toLowerCase())) || + signals[0]; + + this.createSearchableSelect( + `xyX-${panelIdx}`, + signals, + matchSignal(defX), + () => this.plot(panelIdx) + ); + this.createSearchableSelect( + `xyY-${panelIdx}`, + signals, + matchSignal(defY), + () => this.plot(panelIdx) + ); + this.createSearchableSelect( + `xyZ-${panelIdx}`, + signals, + matchSignal(defZ), + () => this.plot(panelIdx) + ); }); this.plot('0'); @@ -101,24 +128,102 @@ export const XYAnalysis = { this.updateTimeline(); }, - setSelectValue(id, searchStr) { - const sel = document.getElementById(id); - for (let opt of sel.options) { - if (opt.value.includes(searchStr)) { - sel.value = opt.value; - break; - } + createSearchableSelect(elementId, options, defaultValue, onChangeCallback) { + let container = document.getElementById(elementId); + if (!container) return; + + if (container.tagName === 'SELECT') { + const div = document.createElement('div'); + div.id = elementId; + div.className = container.className; + div.style.cssText = container.style.cssText; + div.classList.add('searchable-select-wrapper'); + container.parentNode.replaceChild(div, container); + container = div; + } + + container.innerHTML = ''; + if (!container.classList.contains('searchable-select-wrapper')) { + container.classList.add('searchable-select-wrapper'); } + container.style.marginBottom = '5px'; + + const input = document.createElement('input'); + input.type = 'text'; + input.className = 'searchable-input'; + input.value = defaultValue || ''; + input.placeholder = 'Search...'; + input.setAttribute('data-selected-value', defaultValue || ''); + + const list = document.createElement('div'); + list.className = 'search-results-list'; + + const renderList = (filter = '') => { + list.innerHTML = ''; + const lower = filter.toLowerCase(); + const filtered = options.filter((o) => o.toLowerCase().includes(lower)); + + if (filtered.length === 0) { + const noRes = document.createElement('div'); + noRes.className = 'search-option'; + noRes.style.color = '#999'; + noRes.innerText = 'No signals found'; + list.appendChild(noRes); + } else { + filtered.forEach((opt) => { + const item = document.createElement('div'); + item.className = 'search-option'; + item.innerText = opt; + item.onclick = () => { + input.value = opt; + input.setAttribute('data-selected-value', opt); + list.style.display = 'none'; + if (onChangeCallback) onChangeCallback(opt); + }; + list.appendChild(item); + }); + } + }; + + input.onfocus = () => { + renderList(input.value); + list.style.display = 'block'; + }; + + input.oninput = (e) => { + renderList(e.target.value); + list.style.display = 'block'; + }; + + document.addEventListener('click', (e) => { + if (!container.contains(e.target)) { + list.style.display = 'none'; + } + }); + + container.appendChild(input); + container.appendChild(list); + }, + + getInputValue(containerId) { + const container = document.getElementById(containerId); + if (!container) return ''; + if (container.tagName === 'SELECT') return container.value; + const input = container.querySelector('input'); + return input ? input.value : ''; }, plot(panelIdx) { - const fileIdx = document.getElementById('xyGlobalFile').value; - const xSig = document.getElementById(`xyX-${panelIdx}`).value; - const ySig = document.getElementById(`xyY-${panelIdx}`).value; - const zSig = document.getElementById(`xyZ-${panelIdx}`).value; + const fileIdx = + this.currentFileIndex !== undefined ? this.currentFileIndex : 0; - this.renderChart(panelIdx, fileIdx, xSig, ySig, zSig); + const xSig = this.getInputValue(`xyX-${panelIdx}`); + const ySig = this.getInputValue(`xyY-${panelIdx}`); + const zSig = this.getInputValue(`xyZ-${panelIdx}`); + if (!xSig || !ySig || !zSig) return; + + this.renderChart(panelIdx, fileIdx, xSig, ySig, zSig); this.updateTimeline(); }, @@ -128,20 +233,20 @@ export const XYAnalysis = { }, updateTimeline() { - const fileIdx = document.getElementById('xyGlobalFile').value; + const fileIdx = + this.currentFileIndex !== undefined ? this.currentFileIndex : 0; const signals = new Set(); ['0', '1'].forEach((idx) => { - const x = document.getElementById(`xyX-${idx}`).value; - const y = document.getElementById(`xyY-${idx}`).value; - const z = document.getElementById(`xyZ-${idx}`).value; + const x = this.getInputValue(`xyX-${idx}`); + const y = this.getInputValue(`xyY-${idx}`); + const z = this.getInputValue(`xyZ-${idx}`); if (x) signals.add(x); if (y) signals.add(y); if (z) signals.add(z); }); const uniqueSignals = Array.from(signals).filter((s) => s); - this.renderTimeline(fileIdx, uniqueSignals); }, @@ -171,18 +276,10 @@ export const XYAnalysis = { originalValue: parseFloat(p.y), })); - const defaultColors = [ - '#e6194b', - '#3cb44b', - '#ffe119', - '#4363d8', - '#f58231', - '#911eb4', - ]; - const color = - window.PaletteManager && PaletteManager.getColorForSignal - ? PaletteManager.getColorForSignal(0, idx) - : defaultColors[idx % defaultColors.length]; + const color = PaletteManager.getColorForSignal( + fileIdx, + file.availableSignals.indexOf(sigName) + ); return { label: sigName, @@ -214,7 +311,7 @@ export const XYAnalysis = { ctx.moveTo(x, topY); ctx.lineTo(x, bottomY); ctx.lineWidth = 1; - const isDark = document.body.classList.contains('dark-theme'); + const isDark = document.body.classList.contains('pref-theme-dark'); ctx.strokeStyle = isDark ? 'rgba(255, 255, 255, 0.5)' : 'rgba(0, 0, 0, 0.5)'; @@ -224,8 +321,9 @@ export const XYAnalysis = { }, }; - const isDark = document.body.classList.contains('dark-theme'); + const isDark = document.body.classList.contains('pref-theme-dark'); const textColor = isDark ? '#eee' : '#333'; + const gridColor = isDark ? 'rgba(255,255,255,0.1)' : 'rgba(0,0,0,0.1)'; this.timelineChart = new Chart(ctx, { type: 'line', @@ -245,9 +343,7 @@ export const XYAnalysis = { font: { size: 10 }, color: textColor, }, - grid: { - color: isDark ? 'rgba(255,255,255,0.1)' : 'rgba(0,0,0,0.1)', - }, + grid: { color: gridColor }, ticks: { font: { size: 10 }, color: textColor }, }, y: { display: false, min: -0.05, max: 1.05 }, @@ -287,7 +383,9 @@ export const XYAnalysis = { renderChart(panelIdx, fileIdx, signalX, signalY, signalZ) { const canvasId = `xyCanvas-${panelIdx}`; - const ctx = document.getElementById(canvasId).getContext('2d'); + const canvas = document.getElementById(canvasId); + if (!canvas) return; + const ctx = canvas.getContext('2d'); if (this.charts[panelIdx]) { this.charts[panelIdx].destroy(); @@ -303,7 +401,7 @@ export const XYAnalysis = { this.updateLegend(panelIdx, minZ, maxZ, signalZ); - const isDark = document.body.classList.contains('dark-theme'); + const isDark = document.body.classList.contains('pref-theme-dark'); const color = isDark ? '#eee' : '#333'; const grid = isDark ? 'rgba(255,255,255,0.1)' : 'rgba(0,0,0,0.1)'; @@ -343,10 +441,84 @@ export const XYAnalysis = { legend: { display: false }, datalabels: { display: false }, tooltip: { - position: 'xyFixed', - callbacks: { - label: (ctx) => - `X: ${ctx.raw.x.toFixed(2)}, Y: ${ctx.raw.y.toFixed(2)}, Z: ${ctx.raw.z.toFixed(2)}`, + enabled: false, + position: 'nearest', + external: (context) => { + const { chart, tooltip } = context; + const tooltipEl = this.getOrCreateTooltip(chart); + + if (tooltip.opacity === 0) { + tooltipEl.style.opacity = 0; + return; + } + + if (tooltip.body) { + const file = AppState.files[fileIdx]; + const idxX = file.availableSignals.indexOf(signalX); + const idxY = file.availableSignals.indexOf(signalY); + const idxZ = file.availableSignals.indexOf(signalZ); + + const colorX = PaletteManager.getColorForSignal(fileIdx, idxX); + const colorY = PaletteManager.getColorForSignal(fileIdx, idxY); + const colorZ = PaletteManager.getColorForSignal(fileIdx, idxZ); + + const rawPoint = tooltip.dataPoints[0].raw; + + const tableHead = document.createElement('thead'); + const tableBody = document.createElement('tbody'); + + const makeRow = (color, label, value) => { + const tr = document.createElement('tr'); + tr.style.backgroundColor = 'inherit'; + tr.style.borderWidth = 0; + + const tdColor = document.createElement('td'); + tdColor.style.borderWidth = 0; + + const span = document.createElement('span'); + span.style.background = color; + span.style.borderColor = color; + span.style.borderWidth = '2px'; + span.style.marginRight = '10px'; + span.style.height = '10px'; + span.style.width = '10px'; + span.style.display = 'inline-block'; + span.style.borderRadius = '50%'; + + tdColor.appendChild(span); + + const tdText = document.createElement('td'); + tdText.style.borderWidth = 0; + tdText.style.color = '#fff'; + tdText.innerText = `${label}: ${value.toFixed(2)}`; + + tr.appendChild(tdColor); + tr.appendChild(tdText); + return tr; + }; + + tableBody.appendChild(makeRow(colorX, signalX, rawPoint.x)); + tableBody.appendChild(makeRow(colorY, signalY, rawPoint.y)); + tableBody.appendChild(makeRow(colorZ, signalZ, rawPoint.z)); + + const tableRoot = tooltipEl.querySelector('table'); + tableRoot.innerHTML = ''; + tableRoot.appendChild(tableHead); + tableRoot.appendChild(tableBody); + } + + const { offsetLeft: positionX, offsetTop: positionY } = + chart.canvas; + + tooltipEl.style.opacity = 1; + tooltipEl.style.left = positionX + tooltip.caretX + 'px'; + tooltipEl.style.top = positionY + tooltip.caretY + 'px'; + tooltipEl.style.font = tooltip.options.bodyFont.string; + tooltipEl.style.padding = + tooltip.options.padding + + 'px ' + + tooltip.options.padding + + 'px'; }, }, zoom: { @@ -358,6 +530,34 @@ export const XYAnalysis = { }); }, + getOrCreateTooltip(chart) { + let tooltipEl = chart.canvas.parentNode.querySelector( + 'div.chartjs-tooltip' + ); + + if (!tooltipEl) { + tooltipEl = document.createElement('div'); + tooltipEl.className = 'chartjs-tooltip'; + tooltipEl.style.background = 'rgba(0, 0, 0, 0.8)'; + tooltipEl.style.borderRadius = '3px'; + tooltipEl.style.color = 'white'; + tooltipEl.style.opacity = 1; + tooltipEl.style.pointerEvents = 'none'; + tooltipEl.style.position = 'absolute'; + tooltipEl.style.transform = 'translate(-50%, 0)'; + tooltipEl.style.transition = 'all .1s ease'; + tooltipEl.style.zIndex = 100; + + const table = document.createElement('table'); + table.style.margin = '0px'; + + tooltipEl.appendChild(table); + chart.canvas.parentNode.appendChild(tooltipEl); + } + + return tooltipEl; + }, + updateLegend(panelIdx, min, max, zLabel) { const legend = document.getElementById(`xyLegend-${panelIdx}`); if (!legend) return; From f99557747eab1139ee460ad2c446a880d653a56e Mon Sep 17 00:00:00 2001 From: Tomek Zebrowski Date: Thu, 22 Jan 2026 22:03:34 +0100 Subject: [PATCH 2/7] fix: fix failing tests --- tests/mathchannels.test.js | 24 ++-- tests/xyanalysis.suite.1.test.js | 190 ++++++++++++++++++------------- tests/xyanalysis.suite.2.test.js | 64 +++++++++-- 3 files changed, 174 insertions(+), 104 deletions(-) diff --git a/tests/mathchannels.test.js b/tests/mathchannels.test.js index e0af074..3b1ac41 100644 --- a/tests/mathchannels.test.js +++ b/tests/mathchannels.test.js @@ -392,34 +392,30 @@ describe('MathChannels', () => { }, ]; - // Open modal first to populate formula select window.openMathModal(); - const select = document.getElementById('mathFormulaSelect'); - // Find ID for "multiply_const" (Signal * Factor) - // We assume definitions order or check values. - // Let's iterate options to find 'multiply_const' let targetVal = ''; for (let opt of select.options) { if (opt.value === 'multiply_const') targetVal = opt.value; } select.value = targetVal; - // Trigger change window.onMathFormulaChange(); const container = document.getElementById('mathInputsContainer'); - const inputs = container.querySelectorAll('.template-select'); + // Changed to find both searchable inputs and constant inputs + const inputs = container.querySelectorAll( + '.template-select, .searchable-input' + ); - // multiply_const has 2 inputs: Source (select) and Factor (number) expect(inputs.length).toBe(2); - expect(inputs[0].tagName).toBe('SELECT'); // Signal selector - expect(inputs[1].tagName).toBe('INPUT'); // Constant input - expect(inputs[1].type).toBe('number'); + // Input 0 (Source) is now a text input (Searchable Select) + expect(inputs[0].tagName).toBe('INPUT'); + expect(inputs[0].type).toBe('text'); - // Check auto-name generation - const nameInput = document.getElementById('mathChannelName'); - expect(nameInput.value).toContain('Math:'); + // Input 1 (Constant) remains a number input + expect(inputs[1].tagName).toBe('INPUT'); + expect(inputs[1].type).toBe('number'); }); test('createMathChannel (executeCreation) validates form and calls createChannel', () => { diff --git a/tests/xyanalysis.suite.1.test.js b/tests/xyanalysis.suite.1.test.js index 9a5c3a4..4264122 100644 --- a/tests/xyanalysis.suite.1.test.js +++ b/tests/xyanalysis.suite.1.test.js @@ -22,7 +22,7 @@ const mockChartInstance = { }, }; -// 2. Mock Chart.js with specific Tooltip support to prevent init() crashes +// 2. Mock Chart.js await jest.unstable_mockModule('chart.js', () => { const MockChart = jest.fn(() => mockChartInstance); MockChart.register = jest.fn(); @@ -68,26 +68,29 @@ let Chart; describe('XYAnalysis Controller', () => { beforeEach(async () => { - // 3. Reset modules to clear singleton state (Crucial for 'init' tests) jest.resetModules(); jest.clearAllMocks(); - // 4. Setup DOM *before* importing modules so top-level lookups find elements + // 4. Setup DOM - Note: IDs match what the code expects. + // The code replaces +
- - - +
+
+
- - - +
+
+
@@ -102,7 +105,6 @@ describe('XYAnalysis Controller', () => { measureText: jest.fn(() => ({ width: 0 })), }); - // 5. Dynamic imports to ensure a fresh XYAnalysis instance const xyModule = await import('../src/xyanalysis.js'); XYAnalysis = xyModule.XYAnalysis; @@ -115,15 +117,25 @@ describe('XYAnalysis Controller', () => { if (XYAnalysis.charts) XYAnalysis.charts = [null, null]; if (XYAnalysis.timelineChart) XYAnalysis.timelineChart = null; + XYAnalysis.currentFileIndex = undefined; // Reset state Chart.mockImplementation(() => mockChartInstance); - delete window.PaletteManager; + // Ensure PaletteManager is available globally if code relies on window.PaletteManager + // or checks it. The code imports it, but some checks use window.PaletteManager. + // The mock above handles the import. }); afterEach(() => { jest.restoreAllMocks(); }); + // Helper to get value from the custom searchable select + const getCustomValue = (id) => { + const container = document.getElementById(id); + const input = container.querySelector('input'); + return input ? input.value : ''; + }; + describe('UI Interaction', () => { test('init registers Chart.js plugins', () => { XYAnalysis.init(); @@ -149,8 +161,7 @@ describe('XYAnalysis Controller', () => { expect(modal.style.display).toBe('none'); }); - test('populateGlobalFileSelector fills dropdown and triggers change', () => { - // Prevent crash by providing 'signals' object + test('populateGlobalFileSelector fills searchable list and triggers change', () => { AppState.files = [ { name: 'Trip A', availableSignals: [], signals: {} }, { name: 'Trip B', availableSignals: [], signals: {} }, @@ -159,9 +170,19 @@ describe('XYAnalysis Controller', () => { XYAnalysis.populateGlobalFileSelector(); - const select = document.getElementById('xyGlobalFile'); - expect(select.children.length).toBe(2); - expect(select.children[0].text).toBe('Trip A'); + const container = document.getElementById('xyGlobalFile'); + const input = container.querySelector('input'); + const list = container.querySelector('.search-results-list'); + + // Default selection sets input value, which acts as a filter + expect(input.value).toBe('Trip A'); + + // Clear filter to show all options + input.value = ''; + input.dispatchEvent(new Event('input')); + + expect(list.children.length).toBe(2); + expect(list.children[0].innerText).toBe('Trip A'); expect(spy).toHaveBeenCalled(); }); @@ -182,44 +203,24 @@ describe('XYAnalysis Controller', () => { }, ]; - const globalSel = document.getElementById('xyGlobalFile'); - globalSel.innerHTML = ''; - globalSel.value = '0'; + // Setup global file selector first + XYAnalysis.populateGlobalFileSelector(); const updateTimelineSpy = jest.spyOn(XYAnalysis, 'updateTimeline'); XYAnalysis.onFileChange(); - expect(document.getElementById('xyX-0').value).toBe('Engine Rpm'); - expect(document.getElementById('xyY-0').value).toBe( - 'Intake Manifold Pressure' - ); + expect(getCustomValue('xyX-0')).toBe('Engine Rpm'); + expect(getCustomValue('xyY-0')).toBe('Intake Manifold Pressure'); expect(updateTimelineSpy).toHaveBeenCalled(); }); test('onFileChange handles missing file gracefully', () => { AppState.files = []; - document.getElementById('xyGlobalFile').value = '0'; - + // Manually set index to something invalid or just call it + XYAnalysis.currentFileIndex = 0; expect(() => XYAnalysis.onFileChange()).not.toThrow(); }); - - test('setSelectValue selects option if partial match found', () => { - const select = document.getElementById('xyX-0'); - select.innerHTML = ''; - - XYAnalysis.setSelectValue('xyX-0', 'Signal Name'); - expect(select.value).toBe('Some Long Signal Name'); - }); - - test('setSelectValue does nothing if no match found', () => { - const select = document.getElementById('xyX-0'); - select.innerHTML = ''; - select.value = 'A'; - - XYAnalysis.setSelectValue('xyX-0', 'Z'); - expect(select.value).toBe('A'); - }); }); describe('Legend Logic', () => { @@ -320,22 +321,27 @@ describe('XYAnalysis Controller', () => { expect(mockChartInstance.resetZoom).toHaveBeenCalledTimes(2); }); - test('Scatter tooltip callback formats values correctly', () => { + test('Scatter chart uses external tooltip handler', () => { jest .spyOn(XYAnalysis, 'generateScatterData') .mockReturnValue([{ x: 1, y: 2, z: 3 }]); + // Need valid file with availableSignals for tooltip logic + AppState.files = [ + { + availableSignals: ['A', 'B', 'C'], + signals: {}, + }, + ]; + XYAnalysis.renderChart('0', 0, 'A', 'B', 'C'); const config = Chart.mock.calls[0][1]; - const callback = config.options.plugins.tooltip.callbacks.label; - const context = { raw: { x: 1.111, y: 2.222, z: 3.333 } }; - - const text = callback(context); + const tooltipConfig = config.options.plugins.tooltip; - expect(text).toContain('X: 1.11'); - expect(text).toContain('Y: 2.22'); - expect(text).toContain('Z: 3.33'); + // New implementation disables default tooltip and uses external + expect(tooltipConfig.enabled).toBe(false); + expect(typeof tooltipConfig.external).toBe('function'); }); }); @@ -344,6 +350,7 @@ describe('XYAnalysis Controller', () => { AppState.files = [ { startTime: 1000, + availableSignals: ['RPM', 'Boost'], // Required for color lookup signals: { RPM: [ { x: 1000, y: 0 }, @@ -371,7 +378,15 @@ describe('XYAnalysis Controller', () => { }); test('Timeline tooltip returns original values', () => { - AppState.files = [{ startTime: 0, signals: { S1: [{ x: 0, y: 50 }] } }]; + // Required for color lookup inside renderTimeline map loop + AppState.files = [ + { + startTime: 0, + availableSignals: ['S1'], + signals: { S1: [{ x: 0, y: 50 }] }, + }, + ]; + XYAnalysis.renderTimeline(0, ['S1']); const config = Chart.mock.calls[0][1]; @@ -385,11 +400,16 @@ describe('XYAnalysis Controller', () => { }); test('renderTimeline uses PaletteManager module color', () => { - AppState.files = [{ startTime: 0, signals: { SigA: [{ x: 0, y: 0 }] } }]; - window.PaletteManager = { getColorForSignal: jest.fn(() => '#123456') }; - + AppState.files = [ + { + startTime: 0, + availableSignals: ['SigA'], + signals: { SigA: [{ x: 0, y: 0 }] }, + }, + ]; + // Removed erroneous require() + // The PaletteManager mock is active via jest.unstable_mockModule XYAnalysis.renderTimeline(0, ['SigA']); - const config = Chart.mock.calls[0][1]; expect(config.data.datasets[0].borderColor).toBe('#ff0000'); }); @@ -398,6 +418,7 @@ describe('XYAnalysis Controller', () => { AppState.files = [ { startTime: 0, + availableSignals: ['Flat'], signals: { Flat: [ { x: 0, y: 100 }, @@ -421,20 +442,34 @@ describe('XYAnalysis Controller', () => { }); test('updateTimeline aggregates signals and calls render', () => { - const setVal = (id, val) => { - const el = document.getElementById(id); - el.innerHTML = ``; - el.value = val; + // Helper to set value in custom input + const setCustomVal = (id, val) => { + const container = document.getElementById(id); + // Ensure container has input (it should from beforeEach) + if (!container.querySelector('input')) { + container.innerHTML = ``; + } else { + container.querySelector('input').value = val; + } }; - setVal('xyGlobalFile', '0'); - setVal('xyX-0', 'S1'); - setVal('xyY-0', 'S2'); - setVal('xyZ-0', 'S1'); - setVal('xyX-1', 'S3'); - setVal('xyY-1', ''); - setVal('xyZ-1', ''); - AppState.files = [{ startTime: 0, signals: { S1: [], S2: [], S3: [] } }]; + // Mock createSearchableSelect behavior manually since we are not clicking UI + setCustomVal('xyGlobalFile', '0'); + setCustomVal('xyX-0', 'S1'); + setCustomVal('xyY-0', 'S2'); + setCustomVal('xyZ-0', 'S1'); + setCustomVal('xyX-1', 'S3'); + setCustomVal('xyY-1', ''); + setCustomVal('xyZ-1', ''); + + AppState.files = [ + { + startTime: 0, + availableSignals: ['S1', 'S2', 'S3'], + signals: { S1: [], S2: [], S3: [] }, + }, + ]; + XYAnalysis.currentFileIndex = 0; const spy = jest.spyOn(XYAnalysis, 'renderTimeline'); @@ -454,20 +489,16 @@ describe('XYAnalysis Controller', () => { .spyOn(XYAnalysis, 'updateTimeline') .mockImplementation(() => {}); - const setVal = (id, val) => { - const el = document.getElementById(id); - el.innerHTML = ``; - el.value = val; + // Manually setup inputs + const setCustomVal = (id, val) => { + document.getElementById(id).innerHTML = ``; }; - - document.getElementById('xyGlobalFile').innerHTML = - ''; - document.getElementById('xyGlobalFile').value = '0'; - setVal('xyX-0', 'A'); - setVal('xyY-0', 'B'); - setVal('xyZ-0', 'C'); + setCustomVal('xyX-0', 'A'); + setCustomVal('xyY-0', 'B'); + setCustomVal('xyZ-0', 'C'); AppState.files = [{ name: 'F', startTime: 0, signals: {} }]; + XYAnalysis.currentFileIndex = 0; XYAnalysis.plot('0'); @@ -486,6 +517,7 @@ describe('XYAnalysis Controller', () => { test('renderChart configures Axis Titles and Zoom options', () => { AppState.files = [ { + availableSignals: ['X', 'Y', 'Z'], signals: { X: [{ x: 1, y: 1 }], Y: [{ x: 1, y: 1 }], diff --git a/tests/xyanalysis.suite.2.test.js b/tests/xyanalysis.suite.2.test.js index a0bb384..3bb0b11 100644 --- a/tests/xyanalysis.suite.2.test.js +++ b/tests/xyanalysis.suite.2.test.js @@ -184,7 +184,13 @@ describe('XYAnalysis Comprehensive Coverage', () => { describe('Timeline Rendering', () => { test('renderTimeline() skips signals not found in file', () => { - AppState.files = [{ startTime: 0, signals: { A: [{ x: 0, y: 0 }] } }]; + AppState.files = [ + { + startTime: 0, + availableSignals: ['A'], // ADDED: needed for color lookup + signals: { A: [{ x: 0, y: 0 }] }, + }, + ]; XYAnalysis.renderTimeline(0, ['A', 'B']); @@ -198,6 +204,7 @@ describe('XYAnalysis Comprehensive Coverage', () => { AppState.files = [ { startTime: 0, + availableSignals: ['Flat'], // ADDED signals: { Flat: [ { x: 0, y: 100 }, @@ -216,7 +223,13 @@ describe('XYAnalysis Comprehensive Coverage', () => { }); test('Tooltip Callback execution (Timeline)', () => { - AppState.files = [{ startTime: 0, signals: { S1: [{ x: 0, y: 50 }] } }]; + AppState.files = [ + { + startTime: 0, + availableSignals: ['S1'], // ADDED + signals: { S1: [{ x: 0, y: 50 }] }, + }, + ]; XYAnalysis.renderTimeline(0, ['S1']); const config = Chart.mock.calls[0][1]; @@ -232,34 +245,63 @@ describe('XYAnalysis Comprehensive Coverage', () => { }); test('Color Logic: Uses window.PaletteManager if present', () => { - AppState.files = [{ startTime: 0, signals: { S1: [{ x: 0, y: 0 }] } }]; + AppState.files = [ + { + startTime: 0, + availableSignals: ['S1'], // ADDED + signals: { S1: [{ x: 0, y: 0 }] }, + }, + ]; window.PaletteManager = { getColorForSignal: jest.fn(() => '#ABCDEF') }; XYAnalysis.renderTimeline(0, ['S1']); const config = Chart.mock.calls[0][1]; + // Note: Test expects #ff0000 because we mock the module import at the top + // which overrides the window object logic in some environments depending on import order. + // However, the code prioritizes window.PaletteManager if it exists. + // Let's verify the behavior based on the code: + // const color = window.PaletteManager ... ? ... : ... + + // Since we mocked the module return above to #ff0000, and the code imports it, + // usually standard ES modules are read-only bindings. + // The implementation in xyanalysis.js uses `PaletteManager.getColorForSignal`. + // The test sets `window.PaletteManager`. + + // If the source code uses `import { PaletteManager } ...` and calls `PaletteManager.getColor...` + // setting `window.PaletteManager` might be ignored unless the source code explicitly checks `window.PaletteManager`. + // Looking at the provided source code: + // `const color = window.PaletteManager && PaletteManager.getColorForSignal ...` + + // It checks window.PaletteManager existence, but calls the imported PaletteManager object. + // So the mock at the top of this file controls the output. expect(config.data.datasets[0].borderColor).toBe('#ff0000'); }); }); describe('Scatter Plot Interaction', () => { - test('Tooltip Callback execution (Scatter)', () => { + test('Scatter Chart uses External Tooltip Handler', () => { jest .spyOn(XYAnalysis, 'generateScatterData') .mockReturnValue([{ x: 1, y: 2, z: 3 }]); + // Needs availableSignals for internal logic inside tooltip (even if not called here) + AppState.files = [ + { + availableSignals: ['A', 'B', 'C'], + signals: {}, + }, + ]; + XYAnalysis.renderChart('0', 0, 'A', 'B', 'C'); const config = Chart.mock.calls[0][1]; - const callback = config.options.plugins.tooltip.callbacks.label; - - const context = { raw: { x: 1.111, y: 2.222, z: 3.333 } }; - const text = callback(context); + const tooltipConfig = config.options.plugins.tooltip; - expect(text).toContain('X: 1.11'); - expect(text).toContain('Y: 2.22'); - expect(text).toContain('Z: 3.33'); + // Assert that we switched to external tooltip + expect(tooltipConfig.enabled).toBe(false); + expect(typeof tooltipConfig.external).toBe('function'); }); }); }); From 07d0cdcfe487ab6c58d7dcd6070cba5015ca0a33 Mon Sep 17 00:00:00 2001 From: Tomek Zebrowski Date: Thu, 22 Jan 2026 22:21:00 +0100 Subject: [PATCH 3/7] feat: add more junit tests --- tests/xyanalysis.suite.3.test.js | 343 +++++++++++++++++++++++++++++++ tests/xyanalysis.suite.4test.js | 200 ++++++++++++++++++ 2 files changed, 543 insertions(+) create mode 100644 tests/xyanalysis.suite.3.test.js create mode 100644 tests/xyanalysis.suite.4test.js diff --git a/tests/xyanalysis.suite.3.test.js b/tests/xyanalysis.suite.3.test.js new file mode 100644 index 0000000..5b37b04 --- /dev/null +++ b/tests/xyanalysis.suite.3.test.js @@ -0,0 +1,343 @@ +import { + jest, + describe, + test, + expect, + beforeEach, + afterEach, +} from '@jest/globals'; + +const mockChartInstance = { + destroy: jest.fn(), + update: jest.fn(), + resetZoom: jest.fn(), + canvas: { + parentNode: { + querySelector: jest.fn(), + appendChild: jest.fn(), + }, + offsetLeft: 0, + offsetTop: 0, + }, + data: { datasets: [] }, + options: { + plugins: { tooltip: { external: null } }, + scales: { x: {}, y: {} }, + }, +}; + +await jest.unstable_mockModule('chart.js', () => { + const MockChart = jest.fn(() => mockChartInstance); + MockChart.register = jest.fn(); + const MockTooltip = jest.fn(); + MockTooltip.positioners = {}; + return { + __esModule: true, + Chart: MockChart, + ScatterController: jest.fn(), + LineController: jest.fn(), + PointElement: jest.fn(), + LineElement: jest.fn(), + LinearScale: jest.fn(), + TimeScale: jest.fn(), + Legend: jest.fn(), + Tooltip: MockTooltip, + _adapters: { _date: {} }, + }; +}); + +await jest.unstable_mockModule('chartjs-adapter-date-fns', () => ({})); +await jest.unstable_mockModule('chartjs-plugin-zoom', () => ({ + default: { id: 'zoom' }, +})); +await jest.unstable_mockModule('../src/ui.js', () => ({ UI: {} })); +await jest.unstable_mockModule('../src/config.js', () => ({ + AppState: { files: [] }, +})); +await jest.unstable_mockModule('../src/palettemanager.js', () => ({ + PaletteManager: { getColorForSignal: jest.fn(() => '#ff0000') }, +})); + +const { XYAnalysis } = await import('../src/xyanalysis.js'); +const { AppState } = await import('../src/config.js'); +const { Chart, Tooltip } = await import('chart.js'); + +describe('XYAnalysis Suite 4', () => { + beforeEach(() => { + jest.clearAllMocks(); + AppState.files = []; + + document.body.innerHTML = ` + +
+
+ +
+
+ +
+ +
+ +
+ + + `; + + HTMLCanvasElement.prototype.getContext = jest.fn(() => ({ + save: jest.fn(), + restore: jest.fn(), + beginPath: jest.fn(), + moveTo: jest.fn(), + lineTo: jest.fn(), + stroke: jest.fn(), + fill: jest.fn(), + fillRect: jest.fn(), + })); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + describe('Tooltip Positioner', () => { + test('xyFixed positioner returns correct coordinates', () => { + XYAnalysis.init(); + const positioner = Tooltip.positioners.xyFixed; + + const context = { + chart: { + chartArea: { top: 50, right: 500 }, + }, + }; + + const pos = positioner.call(context, [], {}); + expect(pos).toEqual({ x: 490, y: 60 }); + }); + + test('xyFixed returns undefined if no chart', () => { + XYAnalysis.init(); + const positioner = Tooltip.positioners.xyFixed; + const pos = positioner.call({}, [], {}); + expect(pos).toBeUndefined(); + }); + }); + + describe('Modal Logic', () => { + test('openXYModal adjusts split view styles', () => { + XYAnalysis.openXYModal(); + const split = document.getElementById('xySplitView'); + const timeline = document.getElementById('xyTimelineView'); + + expect(split.style.flex).toMatch(/^3/); + expect(timeline.style.flex).toMatch(/^1/); + }); + }); + + describe('Searchable Select Interactions', () => { + test('Replaces SELECT element with custom DIV wrapper', () => { + const container = document.getElementById('xyX-0'); + container.outerHTML = + ''; + + XYAnalysis.createSearchableSelect('xyX-0', ['A', 'B'], 'A', jest.fn()); + + const newEl = document.getElementById('xyX-0'); + expect(newEl.tagName).toBe('DIV'); + expect(newEl.className).toContain('test-class'); + expect(newEl.className).toContain('searchable-select-wrapper'); + expect(newEl.style.color).toBe('red'); + }); + + test('Filter logic: shows "No signals found"', () => { + XYAnalysis.createSearchableSelect( + 'xyGlobalFile', + ['OptionA'], + '', + jest.fn() + ); + const container = document.getElementById('xyGlobalFile'); + const input = container.querySelector('input'); + const list = container.querySelector('.search-results-list'); + + input.value = 'XYZ'; + input.dispatchEvent(new Event('input')); + + expect(list.children.length).toBe(1); + expect(list.children[0].innerText).toBe('No signals found'); + }); + + test('Clicking option updates value and hides list', () => { + const cb = jest.fn(); + XYAnalysis.createSearchableSelect('xyGlobalFile', ['OptionA'], '', cb); + const container = document.getElementById('xyGlobalFile'); + const input = container.querySelector('input'); + const list = container.querySelector('.search-results-list'); + + input.focus(); + const option = list.children[0]; + option.click(); + + expect(input.value).toBe('OptionA'); + expect(list.style.display).toBe('none'); + expect(cb).toHaveBeenCalledWith('OptionA'); + }); + + test('Clicking outside closes list', () => { + XYAnalysis.createSearchableSelect('xyGlobalFile', ['A'], '', jest.fn()); + const container = document.getElementById('xyGlobalFile'); + const input = container.querySelector('input'); + const list = container.querySelector('.search-results-list'); + + input.focus(); + expect(list.style.display).toBe('block'); + + document.body.click(); + + expect(list.style.display).toBe('none'); + }); + + test('getInputValue handles missing input gracefully', () => { + document.getElementById('xyX-0').innerHTML = '
Broken
'; + const val = XYAnalysis.getInputValue('xyX-0'); + expect(val).toBe(''); + }); + }); + + describe('Custom Tooltip Logic', () => { + test('renderChart configures external tooltip and it renders HTML correctly', () => { + AppState.files = [ + { + availableSignals: ['RPM', 'MAP', 'MAF'], + signals: {}, + }, + ]; + + jest + .spyOn(XYAnalysis, 'generateScatterData') + .mockReturnValue([{ x: 1, y: 1, z: 1 }]); + XYAnalysis.renderChart('0', 0, 'RPM', 'MAP', 'MAF'); + + const chartCall = Chart.mock.calls[0]; + const config = chartCall[1]; + const externalHandler = config.options.plugins.tooltip.external; + + expect(externalHandler).toBeDefined(); + + // --- Simulate Tooltip Call --- + const mockTooltipEl = document.createElement('div'); + mockTooltipEl.className = 'chartjs-tooltip'; + const mockTable = document.createElement('table'); + mockTooltipEl.appendChild(mockTable); + + // FIXED: Attach to body to ensure innerText/rendering works in JSDOM + document.body.appendChild(mockTooltipEl); + + const mockChart = { + canvas: { + parentNode: { + querySelector: jest.fn(() => mockTooltipEl), + appendChild: jest.fn(), + }, + offsetLeft: 10, + offsetTop: 20, + }, + }; + + const context = { + chart: mockChart, + tooltip: { + opacity: 1, + caretX: 100, + caretY: 100, + options: { padding: 6, bodyFont: { string: '12px Arial' } }, + body: [{}], + dataPoints: [ + { + raw: { x: 1000, y: 1.5, z: 20.5 }, + }, + ], + }, + }; + + externalHandler(context); + + const rows = mockTable.querySelectorAll('tr'); + expect(rows.length).toBe(3); + + // FIXED: Use innerHTML to verify content as textContent can be flaky in detached nodes in JSDOM + expect(rows[0].innerHTML).toContain('RPM: 1000.00'); + expect(rows[1].innerHTML).toContain('MAP: 1.50'); + expect(rows[2].innerHTML).toContain('MAF: 20.50'); + + const colorDot = rows[0].querySelector('span'); + expect(colorDot.style.background).toBe('rgb(255, 0, 0)'); + }); + + test('External tooltip hides when opacity is 0', () => { + // FIXED: generateScatterData must return data, otherwise renderChart returns early + // and Chart is never instantiated, causing the test to fail when accessing mock calls. + jest + .spyOn(XYAnalysis, 'generateScatterData') + .mockReturnValue([{ x: 1, y: 1, z: 1 }]); + + XYAnalysis.renderChart('0', 0, 'A', 'B', 'C'); + + const config = Chart.mock.calls[0][1]; + const handler = config.options.plugins.tooltip.external; + + const mockEl = document.createElement('div'); + const mockChart = { + canvas: { parentNode: { querySelector: () => mockEl } }, + }; + + handler({ chart: mockChart, tooltip: { opacity: 0 } }); + + expect(mockEl.style.opacity).toBe('0'); + }); + }); + + describe('Timeline Hover Plugin', () => { + test('Timeline hover plugin draws line on active tooltip', () => { + AppState.files = [ + { + startTime: 0, + availableSignals: ['SigA'], + signals: { SigA: [{ x: 0, y: 0 }] }, + }, + ]; + + XYAnalysis.renderTimeline(0, ['SigA']); + + const chartCall = Chart.mock.calls[0]; + const config = chartCall[1]; + const hoverPlugin = config.plugins.find((p) => p.id === 'xyHoverLine'); + + expect(hoverPlugin).toBeDefined(); + + const mockCtx = { + save: jest.fn(), + beginPath: jest.fn(), + moveTo: jest.fn(), + lineTo: jest.fn(), + stroke: jest.fn(), + restore: jest.fn(), + lineWidth: 0, + strokeStyle: '', + }; + + const mockChart = { + ctx: mockCtx, + tooltip: { _active: [{ element: { x: 50 } }] }, + scales: { y: { top: 10, bottom: 100 } }, + }; + + hoverPlugin.afterDraw(mockChart); + + expect(mockCtx.beginPath).toHaveBeenCalled(); + expect(mockCtx.moveTo).toHaveBeenCalledWith(50, 10); + expect(mockCtx.lineTo).toHaveBeenCalledWith(50, 100); + expect(mockCtx.stroke).toHaveBeenCalled(); + }); + }); +}); diff --git a/tests/xyanalysis.suite.4test.js b/tests/xyanalysis.suite.4test.js new file mode 100644 index 0000000..fe105e6 --- /dev/null +++ b/tests/xyanalysis.suite.4test.js @@ -0,0 +1,200 @@ +import { + jest, + describe, + test, + expect, + beforeEach, + afterEach, +} from '@jest/globals'; + +const mockChartInstance = { + destroy: jest.fn(), + update: jest.fn(), + resetZoom: jest.fn(), + canvas: { + parentNode: { + querySelector: jest.fn(), + appendChild: jest.fn(), + }, + offsetLeft: 0, + offsetTop: 0, + }, + data: { datasets: [] }, + options: { + plugins: { tooltip: { external: null } }, + scales: { x: {}, y: {} }, + }, +}; + +await jest.unstable_mockModule('chart.js', () => { + const MockChart = jest.fn(() => mockChartInstance); + MockChart.register = jest.fn(); + const MockTooltip = jest.fn(); + MockTooltip.positioners = {}; + return { + __esModule: true, + Chart: MockChart, + ScatterController: jest.fn(), + LineController: jest.fn(), + PointElement: jest.fn(), + LineElement: jest.fn(), + LinearScale: jest.fn(), + TimeScale: jest.fn(), + Legend: jest.fn(), + Tooltip: MockTooltip, + _adapters: { _date: {} }, + }; +}); + +await jest.unstable_mockModule('chartjs-adapter-date-fns', () => ({})); +await jest.unstable_mockModule('chartjs-plugin-zoom', () => ({ + default: { id: 'zoom' }, +})); +await jest.unstable_mockModule('../src/ui.js', () => ({ UI: {} })); +await jest.unstable_mockModule('../src/config.js', () => ({ + AppState: { files: [] }, +})); +await jest.unstable_mockModule('../src/palettemanager.js', () => ({ + PaletteManager: { getColorForSignal: jest.fn(() => '#ff0000') }, +})); + +const { XYAnalysis } = await import('../src/xyanalysis.js'); +const { AppState } = await import('../src/config.js'); +const { Chart, Tooltip } = await import('chart.js'); + +describe('XYAnalysis Suite 5', () => { + beforeEach(() => { + jest.clearAllMocks(); + AppState.files = []; + + // --- JSDOM innerText Hack --- + Object.defineProperty(HTMLElement.prototype, 'innerText', { + get() { + return this.textContent; + }, + set(value) { + this.textContent = value; + }, + configurable: true, + }); + // ----------------------------- + + document.body.innerHTML = ` + +
+
+
+
+ +
+ + `; + + HTMLCanvasElement.prototype.getContext = jest.fn(() => ({ + save: jest.fn(), + restore: jest.fn(), + beginPath: jest.fn(), + moveTo: jest.fn(), + lineTo: jest.fn(), + stroke: jest.fn(), + fill: jest.fn(), + fillRect: jest.fn(), + })); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + describe('Modal Logic', () => { + test('openXYModal adjusts split view styles', () => { + XYAnalysis.openXYModal(); + const split = document.getElementById('xySplitView'); + const timeline = document.getElementById('xyTimelineView'); + + expect(split.style.flex).toMatch(/^3/); + expect(timeline.style.flex).toMatch(/^1/); + }); + }); + + describe('Custom Tooltip Logic', () => { + test('renderChart configures external tooltip and it renders HTML correctly', () => { + AppState.files = [ + { + availableSignals: ['RPM', 'MAP', 'MAF'], + signals: {}, + }, + ]; + + jest + .spyOn(XYAnalysis, 'generateScatterData') + .mockReturnValue([{ x: 1, y: 1, z: 1 }]); + XYAnalysis.renderChart('0', 0, 'RPM', 'MAP', 'MAF'); + + const chartCall = Chart.mock.calls[0]; + const config = chartCall[1]; + const externalHandler = config.options.plugins.tooltip.external; + + const mockTooltipEl = document.createElement('div'); + mockTooltipEl.className = 'chartjs-tooltip'; + const mockTable = document.createElement('table'); + mockTooltipEl.appendChild(mockTable); + document.body.appendChild(mockTooltipEl); + + const mockChart = { + canvas: { + parentNode: { + querySelector: jest.fn(() => mockTooltipEl), + appendChild: jest.fn(), + }, + offsetLeft: 10, + offsetTop: 20, + }, + }; + + const context = { + chart: mockChart, + tooltip: { + opacity: 1, + caretX: 100, + caretY: 100, + options: { padding: 6, bodyFont: { string: '12px Arial' } }, + body: [{}], + dataPoints: [ + { + raw: { x: 1000, y: 1.5, z: 20.5 }, + }, + ], + }, + }; + + externalHandler(context); + + const rows = mockTable.querySelectorAll('tr'); + expect(rows.length).toBe(3); + + expect(rows[0].innerHTML).toContain('RPM: 1000.00'); + expect(rows[1].innerHTML).toContain('MAP: 1.50'); + expect(rows[2].innerHTML).toContain('MAF: 20.50'); + }); + + test('External tooltip hides when opacity is 0', () => { + jest + .spyOn(XYAnalysis, 'generateScatterData') + .mockReturnValue([{ x: 1, y: 1, z: 1 }]); + XYAnalysis.renderChart('0', 0, 'A', 'B', 'C'); + + const config = Chart.mock.calls[0][1]; + const handler = config.options.plugins.tooltip.external; + + const mockEl = document.createElement('div'); + const mockChart = { + canvas: { parentNode: { querySelector: () => mockEl } }, + }; + + handler({ chart: mockChart, tooltip: { opacity: 0 } }); + + expect(mockEl.style.opacity).toBe('0'); + }); + }); +}); From 5618e53b1a455eff6dd7187b3b685121a4434b2a Mon Sep 17 00:00:00 2001 From: Tomek Zebrowski Date: Fri, 23 Jan 2026 07:58:34 +0100 Subject: [PATCH 4/7] feat: update tests --- tests/xyanalysis.suite.1.test.js | 2 +- tests/xyanalysis.suite.2.test.js | 2 +- tests/xyanalysis.suite.3.test.js | 86 +++++++++++++++++++------------- 3 files changed, 54 insertions(+), 36 deletions(-) diff --git a/tests/xyanalysis.suite.1.test.js b/tests/xyanalysis.suite.1.test.js index 4264122..6480128 100644 --- a/tests/xyanalysis.suite.1.test.js +++ b/tests/xyanalysis.suite.1.test.js @@ -66,7 +66,7 @@ let XYAnalysis; let AppState; let Chart; -describe('XYAnalysis Controller', () => { +describe('XYAnalysis Suite - 1', () => { beforeEach(async () => { jest.resetModules(); jest.clearAllMocks(); diff --git a/tests/xyanalysis.suite.2.test.js b/tests/xyanalysis.suite.2.test.js index 3bb0b11..317c01b 100644 --- a/tests/xyanalysis.suite.2.test.js +++ b/tests/xyanalysis.suite.2.test.js @@ -58,7 +58,7 @@ const { XYAnalysis } = await import('../src/xyanalysis.js'); const { AppState } = await import('../src/config.js'); const { Chart } = await import('chart.js'); -describe('XYAnalysis Comprehensive Coverage', () => { +describe('XYAnalysis Suite - 2', () => { beforeEach(() => { jest.clearAllMocks(); AppState.files = []; diff --git a/tests/xyanalysis.suite.3.test.js b/tests/xyanalysis.suite.3.test.js index 5b37b04..ba0248e 100644 --- a/tests/xyanalysis.suite.3.test.js +++ b/tests/xyanalysis.suite.3.test.js @@ -62,25 +62,38 @@ const { XYAnalysis } = await import('../src/xyanalysis.js'); const { AppState } = await import('../src/config.js'); const { Chart, Tooltip } = await import('chart.js'); -describe('XYAnalysis Suite 4', () => { +describe('XYAnalysis Suite - 3', () => { + const originalCreateElement = document.createElement.bind(document); + beforeEach(() => { jest.clearAllMocks(); AppState.files = []; + jest.spyOn(document, 'createElement').mockImplementation((tagName) => { + const el = originalCreateElement(tagName); + Object.defineProperty(el, 'innerText', { + get() { + return this.textContent; + }, + set(value) { + this.textContent = value; + }, + configurable: true, + }); + return el; + }); + document.body.innerHTML = `
-
-
- `; @@ -100,17 +113,11 @@ describe('XYAnalysis Suite 4', () => { jest.restoreAllMocks(); }); - describe('Tooltip Positioner', () => { + describe('Initialization and Tooltips', () => { test('xyFixed positioner returns correct coordinates', () => { XYAnalysis.init(); const positioner = Tooltip.positioners.xyFixed; - - const context = { - chart: { - chartArea: { top: 50, right: 500 }, - }, - }; - + const context = { chart: { chartArea: { top: 50, right: 500 } } }; const pos = positioner.call(context, [], {}); expect(pos).toEqual({ x: 490, y: 60 }); }); @@ -123,18 +130,24 @@ describe('XYAnalysis Suite 4', () => { }); }); - describe('Modal Logic', () => { + describe('Modal UI', () => { test('openXYModal adjusts split view styles', () => { XYAnalysis.openXYModal(); const split = document.getElementById('xySplitView'); const timeline = document.getElementById('xyTimelineView'); - expect(split.style.flex).toMatch(/^3/); expect(timeline.style.flex).toMatch(/^1/); }); + + test('closeXYModal hides modal', () => { + const modal = document.getElementById('xyModal'); + modal.style.display = 'flex'; + XYAnalysis.closeXYModal(); + expect(modal.style.display).toBe('none'); + }); }); - describe('Searchable Select Interactions', () => { + describe('Searchable Select Logic', () => { test('Replaces SELECT element with custom DIV wrapper', () => { const container = document.getElementById('xyX-0'); container.outerHTML = @@ -144,12 +157,11 @@ describe('XYAnalysis Suite 4', () => { const newEl = document.getElementById('xyX-0'); expect(newEl.tagName).toBe('DIV'); - expect(newEl.className).toContain('test-class'); expect(newEl.className).toContain('searchable-select-wrapper'); expect(newEl.style.color).toBe('red'); }); - test('Filter logic: shows "No signals found"', () => { + test('Filter logic shows No signals found', () => { XYAnalysis.createSearchableSelect( 'xyGlobalFile', ['OptionA'], @@ -191,9 +203,7 @@ describe('XYAnalysis Suite 4', () => { input.focus(); expect(list.style.display).toBe('block'); - document.body.click(); - expect(list.style.display).toBe('none'); }); @@ -202,10 +212,18 @@ describe('XYAnalysis Suite 4', () => { const val = XYAnalysis.getInputValue('xyX-0'); expect(val).toBe(''); }); + + test('getInputValue handles raw SELECT element', () => { + const container = document.getElementById('xyX-0'); + container.outerHTML = + ''; + const val = XYAnalysis.getInputValue('xyX-0'); + expect(val).toBe('A'); + }); }); - describe('Custom Tooltip Logic', () => { - test('renderChart configures external tooltip and it renders HTML correctly', () => { + describe('Custom Tooltip Rendering', () => { + test('renderChart configures external tooltip and renders HTML', () => { AppState.files = [ { availableSignals: ['RPM', 'MAP', 'MAF'], @@ -222,15 +240,10 @@ describe('XYAnalysis Suite 4', () => { const config = chartCall[1]; const externalHandler = config.options.plugins.tooltip.external; - expect(externalHandler).toBeDefined(); - - // --- Simulate Tooltip Call --- const mockTooltipEl = document.createElement('div'); mockTooltipEl.className = 'chartjs-tooltip'; const mockTable = document.createElement('table'); mockTooltipEl.appendChild(mockTable); - - // FIXED: Attach to body to ensure innerText/rendering works in JSDOM document.body.appendChild(mockTooltipEl); const mockChart = { @@ -264,8 +277,6 @@ describe('XYAnalysis Suite 4', () => { const rows = mockTable.querySelectorAll('tr'); expect(rows.length).toBe(3); - - // FIXED: Use innerHTML to verify content as textContent can be flaky in detached nodes in JSDOM expect(rows[0].innerHTML).toContain('RPM: 1000.00'); expect(rows[1].innerHTML).toContain('MAP: 1.50'); expect(rows[2].innerHTML).toContain('MAF: 20.50'); @@ -275,12 +286,9 @@ describe('XYAnalysis Suite 4', () => { }); test('External tooltip hides when opacity is 0', () => { - // FIXED: generateScatterData must return data, otherwise renderChart returns early - // and Chart is never instantiated, causing the test to fail when accessing mock calls. jest .spyOn(XYAnalysis, 'generateScatterData') .mockReturnValue([{ x: 1, y: 1, z: 1 }]); - XYAnalysis.renderChart('0', 0, 'A', 'B', 'C'); const config = Chart.mock.calls[0][1]; @@ -292,9 +300,21 @@ describe('XYAnalysis Suite 4', () => { }; handler({ chart: mockChart, tooltip: { opacity: 0 } }); - expect(mockEl.style.opacity).toBe('0'); }); + + test('getOrCreateTooltip creates element if missing', () => { + const mockParent = document.createElement('div'); + const mockCanvas = document.createElement('canvas'); + mockParent.appendChild(mockCanvas); + + const chart = { canvas: mockCanvas }; + const tooltip = XYAnalysis.getOrCreateTooltip(chart); + + expect(tooltip).not.toBeNull(); + expect(tooltip.className).toBe('chartjs-tooltip'); + expect(mockParent.querySelector('.chartjs-tooltip')).not.toBeNull(); + }); }); describe('Timeline Hover Plugin', () => { @@ -313,8 +333,6 @@ describe('XYAnalysis Suite 4', () => { const config = chartCall[1]; const hoverPlugin = config.plugins.find((p) => p.id === 'xyHoverLine'); - expect(hoverPlugin).toBeDefined(); - const mockCtx = { save: jest.fn(), beginPath: jest.fn(), From fdd4105c3cfcbd398ef512da528d75c49503b2d0 Mon Sep 17 00:00:00 2001 From: Tomek Zebrowski Date: Fri, 23 Jan 2026 08:05:07 +0100 Subject: [PATCH 5/7] feat: consolidate all test suites --- tests/xyanalysis.suite.1.test.js | 564 ------------------------- tests/xyanalysis.suite.2.test.js | 307 -------------- tests/xyanalysis.suite.3.test.js | 361 ---------------- tests/xyanalysis.suite.4test.js | 200 --------- tests/xyanalysis.test.js | 692 +++++++++++++++++++++++++++++++ 5 files changed, 692 insertions(+), 1432 deletions(-) delete mode 100644 tests/xyanalysis.suite.1.test.js delete mode 100644 tests/xyanalysis.suite.2.test.js delete mode 100644 tests/xyanalysis.suite.3.test.js delete mode 100644 tests/xyanalysis.suite.4test.js create mode 100644 tests/xyanalysis.test.js diff --git a/tests/xyanalysis.suite.1.test.js b/tests/xyanalysis.suite.1.test.js deleted file mode 100644 index 6480128..0000000 --- a/tests/xyanalysis.suite.1.test.js +++ /dev/null @@ -1,564 +0,0 @@ -import { - jest, - describe, - test, - expect, - beforeEach, - afterEach, -} from '@jest/globals'; - -// 1. Define mock instances outside to be reused -const mockChartInstance = { - destroy: jest.fn(), - update: jest.fn(), - draw: jest.fn(), - resetZoom: jest.fn(), - width: 1000, - scales: { x: { min: 0, max: 1000, getValueForPixel: jest.fn() } }, - data: { datasets: [] }, - options: { - plugins: { datalabels: {}, zoom: {}, tooltip: { callbacks: {} } }, - scales: { x: { min: 0, max: 0, title: {} }, y: { title: {} } }, - }, -}; - -// 2. Mock Chart.js -await jest.unstable_mockModule('chart.js', () => { - const MockChart = jest.fn(() => mockChartInstance); - MockChart.register = jest.fn(); - - const MockTooltip = jest.fn(); - MockTooltip.positioners = {}; - - return { - __esModule: true, - Chart: MockChart, - ScatterController: jest.fn(), - LineController: jest.fn(), - PointElement: jest.fn(), - LineElement: jest.fn(), - LinearScale: jest.fn(), - TimeScale: jest.fn(), - Legend: jest.fn(), - Tooltip: MockTooltip, - _adapters: { _date: {} }, - }; -}); - -await jest.unstable_mockModule('chartjs-adapter-date-fns', () => ({})); - -const mockZoomPlugin = { id: 'zoom' }; -await jest.unstable_mockModule('chartjs-plugin-zoom', () => ({ - default: mockZoomPlugin, -})); - -await jest.unstable_mockModule('../src/ui.js', () => ({ UI: {} })); -await jest.unstable_mockModule('../src/config.js', () => ({ - AppState: { files: [] }, -})); - -await jest.unstable_mockModule('../src/palettemanager.js', () => ({ - PaletteManager: { getColorForSignal: jest.fn(() => '#ff0000') }, -})); - -// Variables for fresh module instances per test -let XYAnalysis; -let AppState; -let Chart; - -describe('XYAnalysis Suite - 1', () => { - beforeEach(async () => { - jest.resetModules(); - jest.clearAllMocks(); - - // 4. Setup DOM - Note: IDs match what the code expects. - // The code replaces `; - } else { - container.querySelector('input').value = val; - } - }; - - // Mock createSearchableSelect behavior manually since we are not clicking UI - setCustomVal('xyGlobalFile', '0'); - setCustomVal('xyX-0', 'S1'); - setCustomVal('xyY-0', 'S2'); - setCustomVal('xyZ-0', 'S1'); - setCustomVal('xyX-1', 'S3'); - setCustomVal('xyY-1', ''); - setCustomVal('xyZ-1', ''); - - AppState.files = [ - { - startTime: 0, - availableSignals: ['S1', 'S2', 'S3'], - signals: { S1: [], S2: [], S3: [] }, - }, - ]; - XYAnalysis.currentFileIndex = 0; - - const spy = jest.spyOn(XYAnalysis, 'renderTimeline'); - - XYAnalysis.updateTimeline(); - - expect(spy).toHaveBeenCalledWith( - expect.anything(), - expect.arrayContaining(['S1', 'S2', 'S3']) - ); - }); - - test('plot triggers chart render and timeline update', () => { - const scatterSpy = jest - .spyOn(XYAnalysis, 'renderChart') - .mockImplementation(() => {}); - const timelineSpy = jest - .spyOn(XYAnalysis, 'updateTimeline') - .mockImplementation(() => {}); - - // Manually setup inputs - const setCustomVal = (id, val) => { - document.getElementById(id).innerHTML = ``; - }; - setCustomVal('xyX-0', 'A'); - setCustomVal('xyY-0', 'B'); - setCustomVal('xyZ-0', 'C'); - - AppState.files = [{ name: 'F', startTime: 0, signals: {} }]; - XYAnalysis.currentFileIndex = 0; - - XYAnalysis.plot('0'); - - expect(scatterSpy).toHaveBeenCalled(); - expect(timelineSpy).toHaveBeenCalled(); - }); - }); - - describe('Extended Coverage', () => { - test('getHeatColor generates correct gradient range', () => { - expect(XYAnalysis.getHeatColor(0, 0, 100)).toContain('240'); // Blue - expect(XYAnalysis.getHeatColor(100, 0, 100)).toContain('0'); // Red - expect(XYAnalysis.getHeatColor(50, 0, 100)).toContain('120'); // Green - }); - - test('renderChart configures Axis Titles and Zoom options', () => { - AppState.files = [ - { - availableSignals: ['X', 'Y', 'Z'], - signals: { - X: [{ x: 1, y: 1 }], - Y: [{ x: 1, y: 1 }], - Z: [{ x: 1, y: 1 }], - }, - }, - ]; - jest - .spyOn(XYAnalysis, 'generateScatterData') - .mockReturnValue([{ x: 1, y: 1, z: 1 }]); - - XYAnalysis.renderChart('0', 0, 'X', 'Y', 'Z'); - - expect(Chart).toHaveBeenCalled(); - const config = Chart.mock.calls[0][1]; - - expect(config.options.scales.x.title.text).toBe('X'); - expect(config.options.scales.y.title.text).toBe('Y'); - expect(config.options.plugins.zoom.zoom.wheel.enabled).toBe(true); - }); - - test('generateScatterData handles partial overlap', () => { - AppState.files = [ - { - signals: { - X: [ - { x: 100, y: 1 }, - { x: 200, y: 2 }, - { x: 300, y: 3 }, - ], - Y: [{ x: 200, y: 20 }], - Z: [{ x: 200, y: 30 }], - }, - }, - ]; - - const data = XYAnalysis.generateScatterData(0, 'X', 'Y', 'Z'); - - expect(data).toHaveLength(1); - expect(data[0].x).toBe(2); - expect(data[0].y).toBe(20); - }); - }); -}); diff --git a/tests/xyanalysis.suite.2.test.js b/tests/xyanalysis.suite.2.test.js deleted file mode 100644 index 317c01b..0000000 --- a/tests/xyanalysis.suite.2.test.js +++ /dev/null @@ -1,307 +0,0 @@ -import { - jest, - describe, - test, - expect, - beforeEach, - afterEach, -} from '@jest/globals'; - -const mockChartInstance = { - destroy: jest.fn(), - update: jest.fn(), - draw: jest.fn(), - resetZoom: jest.fn(), - width: 1000, - scales: { x: { min: 0, max: 1000, getValueForPixel: jest.fn() } }, - data: { datasets: [] }, - options: { - plugins: { datalabels: {}, zoom: {}, tooltip: { callbacks: {} } }, - scales: { x: { min: 0, max: 0 } }, - }, -}; - -await jest.unstable_mockModule('chart.js', () => { - const MockChart = jest.fn(() => mockChartInstance); - MockChart.register = jest.fn(); - return { - __esModule: true, - Chart: MockChart, - ScatterController: jest.fn(), - LineController: jest.fn(), - PointElement: jest.fn(), - LineElement: jest.fn(), - LinearScale: jest.fn(), - TimeScale: jest.fn(), - Legend: jest.fn(), - Tooltip: jest.fn(), - _adapters: { _date: {} }, - }; -}); - -await jest.unstable_mockModule('chartjs-adapter-date-fns', () => ({})); -const mockZoomPlugin = { id: 'zoom' }; -await jest.unstable_mockModule('chartjs-plugin-zoom', () => ({ - default: mockZoomPlugin, -})); - -await jest.unstable_mockModule('../src/ui.js', () => ({ UI: {} })); -await jest.unstable_mockModule('../src/config.js', () => ({ - AppState: { files: [] }, -})); - -await jest.unstable_mockModule('../src/palettemanager.js', () => ({ - PaletteManager: { getColorForSignal: jest.fn(() => '#ff0000') }, -})); - -const { XYAnalysis } = await import('../src/xyanalysis.js'); -const { AppState } = await import('../src/config.js'); -const { Chart } = await import('chart.js'); - -describe('XYAnalysis Suite - 2', () => { - beforeEach(() => { - jest.clearAllMocks(); - AppState.files = []; - XYAnalysis.charts = [null, null]; - XYAnalysis.timelineChart = null; - - Chart.mockImplementation(() => mockChartInstance); - - jest.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue({ - canvas: document.createElement('canvas'), - save: jest.fn(), - restore: jest.fn(), - fillRect: jest.fn(), - measureText: jest.fn(() => ({ width: 0 })), - }); - - document.body.innerHTML = ` - - - - - - - - - - - - - - - - - `; - - delete window.PaletteManager; - }); - - afterEach(() => { - jest.restoreAllMocks(); - }); - - describe('Legend Logic', () => { - test('updateLegend() handles min=max edge case (Constant Value)', () => { - XYAnalysis.updateLegend('0', 10, 10, 'Constant'); - const legend = document.getElementById('xyLegend-0'); - const values = legend.querySelectorAll('.legend-values span'); - expect(values[0].innerText).toBe('10.0'); - expect(values[4].innerText).toBe('10.0'); - }); - - test('updateLegend() creates correct gradient structure', () => { - XYAnalysis.updateLegend('0', 0, 100, 'Label'); - const legend = document.getElementById('xyLegend-0'); - expect(legend.querySelector('.gradient-bar')).not.toBeNull(); - expect(legend.querySelector('.z-axis-label').innerText).toBe('Label'); - }); - }); - - describe('Data Synchronization (generateScatterData)', () => { - test('Handles Millisecond Timestamps (Tolerance Logic)', () => { - AppState.files = [ - { - signals: { - X: [{ x: 200000, y: 1 }], - Y: [{ x: 200100, y: 2 }], - Z: [{ x: 200499, y: 3 }], - }, - }, - ]; - - const result = XYAnalysis.generateScatterData(0, 'X', 'Y', 'Z'); - expect(result).toHaveLength(1); - expect(result[0]).toEqual({ x: 1, y: 2, z: 3 }); - }); - - test('Skips points outside Tolerance', () => { - AppState.files = [ - { - signals: { - X: [{ x: 1.0, y: 1 }], - Y: [{ x: 2.0, y: 2 }], - Z: [{ x: 1.0, y: 3 }], - }, - }, - ]; - - const result = XYAnalysis.generateScatterData(0, 'X', 'Y', 'Z'); - expect(result).toHaveLength(0); - }); - - test('Iterates "while" loops when Y/Z lag behind X', () => { - AppState.files = [ - { - signals: { - X: [{ x: 50.0, y: 100 }], - Y: [ - { x: 1.0, y: 1 }, - { x: 2.0, y: 2 }, - { x: 50.0, y: 10 }, - ], - Z: [ - { x: 5.0, y: 5 }, - { x: 50.0, y: 20 }, - ], - }, - }, - ]; - - const result = XYAnalysis.generateScatterData(0, 'X', 'Y', 'Z'); - - expect(result).toHaveLength(1); - expect(result[0]).toEqual({ x: 100, y: 10, z: 20 }); - }); - - test('getHeatColor handles min == max edge case', () => { - const color = XYAnalysis.getHeatColor(10, 10, 10); - expect(color).toBe('hsla(240, 100%, 50%, 0.8)'); - }); - }); - - describe('Timeline Rendering', () => { - test('renderTimeline() skips signals not found in file', () => { - AppState.files = [ - { - startTime: 0, - availableSignals: ['A'], // ADDED: needed for color lookup - signals: { A: [{ x: 0, y: 0 }] }, - }, - ]; - - XYAnalysis.renderTimeline(0, ['A', 'B']); - - expect(Chart).toHaveBeenCalled(); - const config = Chart.mock.calls[0][1]; - expect(config.data.datasets).toHaveLength(1); - expect(config.data.datasets[0].label).toBe('A'); - }); - - test('renderTimeline() handles Min=Max normalization (avoid divide-by-zero)', () => { - AppState.files = [ - { - startTime: 0, - availableSignals: ['Flat'], // ADDED - signals: { - Flat: [ - { x: 0, y: 100 }, - { x: 1, y: 100 }, - ], - }, - }, - ]; - - XYAnalysis.renderTimeline(0, ['Flat']); - - const config = Chart.mock.calls[0][1]; - const data = config.data.datasets[0].data; - - expect(data[0].y).toBe(0); - }); - - test('Tooltip Callback execution (Timeline)', () => { - AppState.files = [ - { - startTime: 0, - availableSignals: ['S1'], // ADDED - signals: { S1: [{ x: 0, y: 50 }] }, - }, - ]; - XYAnalysis.renderTimeline(0, ['S1']); - - const config = Chart.mock.calls[0][1]; - const callback = config.options.plugins.tooltip.callbacks.label; - - const context = { - dataset: { label: 'S1' }, - raw: { originalValue: 50.1234 }, - }; - const text = callback(context); - - expect(text).toBe('S1: 50.12'); - }); - - test('Color Logic: Uses window.PaletteManager if present', () => { - AppState.files = [ - { - startTime: 0, - availableSignals: ['S1'], // ADDED - signals: { S1: [{ x: 0, y: 0 }] }, - }, - ]; - - window.PaletteManager = { getColorForSignal: jest.fn(() => '#ABCDEF') }; - - XYAnalysis.renderTimeline(0, ['S1']); - - const config = Chart.mock.calls[0][1]; - // Note: Test expects #ff0000 because we mock the module import at the top - // which overrides the window object logic in some environments depending on import order. - // However, the code prioritizes window.PaletteManager if it exists. - // Let's verify the behavior based on the code: - // const color = window.PaletteManager ... ? ... : ... - - // Since we mocked the module return above to #ff0000, and the code imports it, - // usually standard ES modules are read-only bindings. - // The implementation in xyanalysis.js uses `PaletteManager.getColorForSignal`. - // The test sets `window.PaletteManager`. - - // If the source code uses `import { PaletteManager } ...` and calls `PaletteManager.getColor...` - // setting `window.PaletteManager` might be ignored unless the source code explicitly checks `window.PaletteManager`. - // Looking at the provided source code: - // `const color = window.PaletteManager && PaletteManager.getColorForSignal ...` - - // It checks window.PaletteManager existence, but calls the imported PaletteManager object. - // So the mock at the top of this file controls the output. - expect(config.data.datasets[0].borderColor).toBe('#ff0000'); - }); - }); - - describe('Scatter Plot Interaction', () => { - test('Scatter Chart uses External Tooltip Handler', () => { - jest - .spyOn(XYAnalysis, 'generateScatterData') - .mockReturnValue([{ x: 1, y: 2, z: 3 }]); - - // Needs availableSignals for internal logic inside tooltip (even if not called here) - AppState.files = [ - { - availableSignals: ['A', 'B', 'C'], - signals: {}, - }, - ]; - - XYAnalysis.renderChart('0', 0, 'A', 'B', 'C'); - - const config = Chart.mock.calls[0][1]; - const tooltipConfig = config.options.plugins.tooltip; - - // Assert that we switched to external tooltip - expect(tooltipConfig.enabled).toBe(false); - expect(typeof tooltipConfig.external).toBe('function'); - }); - }); -}); diff --git a/tests/xyanalysis.suite.3.test.js b/tests/xyanalysis.suite.3.test.js deleted file mode 100644 index ba0248e..0000000 --- a/tests/xyanalysis.suite.3.test.js +++ /dev/null @@ -1,361 +0,0 @@ -import { - jest, - describe, - test, - expect, - beforeEach, - afterEach, -} from '@jest/globals'; - -const mockChartInstance = { - destroy: jest.fn(), - update: jest.fn(), - resetZoom: jest.fn(), - canvas: { - parentNode: { - querySelector: jest.fn(), - appendChild: jest.fn(), - }, - offsetLeft: 0, - offsetTop: 0, - }, - data: { datasets: [] }, - options: { - plugins: { tooltip: { external: null } }, - scales: { x: {}, y: {} }, - }, -}; - -await jest.unstable_mockModule('chart.js', () => { - const MockChart = jest.fn(() => mockChartInstance); - MockChart.register = jest.fn(); - const MockTooltip = jest.fn(); - MockTooltip.positioners = {}; - return { - __esModule: true, - Chart: MockChart, - ScatterController: jest.fn(), - LineController: jest.fn(), - PointElement: jest.fn(), - LineElement: jest.fn(), - LinearScale: jest.fn(), - TimeScale: jest.fn(), - Legend: jest.fn(), - Tooltip: MockTooltip, - _adapters: { _date: {} }, - }; -}); - -await jest.unstable_mockModule('chartjs-adapter-date-fns', () => ({})); -await jest.unstable_mockModule('chartjs-plugin-zoom', () => ({ - default: { id: 'zoom' }, -})); -await jest.unstable_mockModule('../src/ui.js', () => ({ UI: {} })); -await jest.unstable_mockModule('../src/config.js', () => ({ - AppState: { files: [] }, -})); -await jest.unstable_mockModule('../src/palettemanager.js', () => ({ - PaletteManager: { getColorForSignal: jest.fn(() => '#ff0000') }, -})); - -const { XYAnalysis } = await import('../src/xyanalysis.js'); -const { AppState } = await import('../src/config.js'); -const { Chart, Tooltip } = await import('chart.js'); - -describe('XYAnalysis Suite - 3', () => { - const originalCreateElement = document.createElement.bind(document); - - beforeEach(() => { - jest.clearAllMocks(); - AppState.files = []; - - jest.spyOn(document, 'createElement').mockImplementation((tagName) => { - const el = originalCreateElement(tagName); - Object.defineProperty(el, 'innerText', { - get() { - return this.textContent; - }, - set(value) { - this.textContent = value; - }, - configurable: true, - }); - return el; - }); - - document.body.innerHTML = ` - -
-
-
-
- -
-
- -
- - `; - - HTMLCanvasElement.prototype.getContext = jest.fn(() => ({ - save: jest.fn(), - restore: jest.fn(), - beginPath: jest.fn(), - moveTo: jest.fn(), - lineTo: jest.fn(), - stroke: jest.fn(), - fill: jest.fn(), - fillRect: jest.fn(), - })); - }); - - afterEach(() => { - jest.restoreAllMocks(); - }); - - describe('Initialization and Tooltips', () => { - test('xyFixed positioner returns correct coordinates', () => { - XYAnalysis.init(); - const positioner = Tooltip.positioners.xyFixed; - const context = { chart: { chartArea: { top: 50, right: 500 } } }; - const pos = positioner.call(context, [], {}); - expect(pos).toEqual({ x: 490, y: 60 }); - }); - - test('xyFixed returns undefined if no chart', () => { - XYAnalysis.init(); - const positioner = Tooltip.positioners.xyFixed; - const pos = positioner.call({}, [], {}); - expect(pos).toBeUndefined(); - }); - }); - - describe('Modal UI', () => { - test('openXYModal adjusts split view styles', () => { - XYAnalysis.openXYModal(); - const split = document.getElementById('xySplitView'); - const timeline = document.getElementById('xyTimelineView'); - expect(split.style.flex).toMatch(/^3/); - expect(timeline.style.flex).toMatch(/^1/); - }); - - test('closeXYModal hides modal', () => { - const modal = document.getElementById('xyModal'); - modal.style.display = 'flex'; - XYAnalysis.closeXYModal(); - expect(modal.style.display).toBe('none'); - }); - }); - - describe('Searchable Select Logic', () => { - test('Replaces SELECT element with custom DIV wrapper', () => { - const container = document.getElementById('xyX-0'); - container.outerHTML = - ''; - - XYAnalysis.createSearchableSelect('xyX-0', ['A', 'B'], 'A', jest.fn()); - - const newEl = document.getElementById('xyX-0'); - expect(newEl.tagName).toBe('DIV'); - expect(newEl.className).toContain('searchable-select-wrapper'); - expect(newEl.style.color).toBe('red'); - }); - - test('Filter logic shows No signals found', () => { - XYAnalysis.createSearchableSelect( - 'xyGlobalFile', - ['OptionA'], - '', - jest.fn() - ); - const container = document.getElementById('xyGlobalFile'); - const input = container.querySelector('input'); - const list = container.querySelector('.search-results-list'); - - input.value = 'XYZ'; - input.dispatchEvent(new Event('input')); - - expect(list.children.length).toBe(1); - expect(list.children[0].innerText).toBe('No signals found'); - }); - - test('Clicking option updates value and hides list', () => { - const cb = jest.fn(); - XYAnalysis.createSearchableSelect('xyGlobalFile', ['OptionA'], '', cb); - const container = document.getElementById('xyGlobalFile'); - const input = container.querySelector('input'); - const list = container.querySelector('.search-results-list'); - - input.focus(); - const option = list.children[0]; - option.click(); - - expect(input.value).toBe('OptionA'); - expect(list.style.display).toBe('none'); - expect(cb).toHaveBeenCalledWith('OptionA'); - }); - - test('Clicking outside closes list', () => { - XYAnalysis.createSearchableSelect('xyGlobalFile', ['A'], '', jest.fn()); - const container = document.getElementById('xyGlobalFile'); - const input = container.querySelector('input'); - const list = container.querySelector('.search-results-list'); - - input.focus(); - expect(list.style.display).toBe('block'); - document.body.click(); - expect(list.style.display).toBe('none'); - }); - - test('getInputValue handles missing input gracefully', () => { - document.getElementById('xyX-0').innerHTML = '
Broken
'; - const val = XYAnalysis.getInputValue('xyX-0'); - expect(val).toBe(''); - }); - - test('getInputValue handles raw SELECT element', () => { - const container = document.getElementById('xyX-0'); - container.outerHTML = - ''; - const val = XYAnalysis.getInputValue('xyX-0'); - expect(val).toBe('A'); - }); - }); - - describe('Custom Tooltip Rendering', () => { - test('renderChart configures external tooltip and renders HTML', () => { - AppState.files = [ - { - availableSignals: ['RPM', 'MAP', 'MAF'], - signals: {}, - }, - ]; - - jest - .spyOn(XYAnalysis, 'generateScatterData') - .mockReturnValue([{ x: 1, y: 1, z: 1 }]); - XYAnalysis.renderChart('0', 0, 'RPM', 'MAP', 'MAF'); - - const chartCall = Chart.mock.calls[0]; - const config = chartCall[1]; - const externalHandler = config.options.plugins.tooltip.external; - - const mockTooltipEl = document.createElement('div'); - mockTooltipEl.className = 'chartjs-tooltip'; - const mockTable = document.createElement('table'); - mockTooltipEl.appendChild(mockTable); - document.body.appendChild(mockTooltipEl); - - const mockChart = { - canvas: { - parentNode: { - querySelector: jest.fn(() => mockTooltipEl), - appendChild: jest.fn(), - }, - offsetLeft: 10, - offsetTop: 20, - }, - }; - - const context = { - chart: mockChart, - tooltip: { - opacity: 1, - caretX: 100, - caretY: 100, - options: { padding: 6, bodyFont: { string: '12px Arial' } }, - body: [{}], - dataPoints: [ - { - raw: { x: 1000, y: 1.5, z: 20.5 }, - }, - ], - }, - }; - - externalHandler(context); - - const rows = mockTable.querySelectorAll('tr'); - expect(rows.length).toBe(3); - expect(rows[0].innerHTML).toContain('RPM: 1000.00'); - expect(rows[1].innerHTML).toContain('MAP: 1.50'); - expect(rows[2].innerHTML).toContain('MAF: 20.50'); - - const colorDot = rows[0].querySelector('span'); - expect(colorDot.style.background).toBe('rgb(255, 0, 0)'); - }); - - test('External tooltip hides when opacity is 0', () => { - jest - .spyOn(XYAnalysis, 'generateScatterData') - .mockReturnValue([{ x: 1, y: 1, z: 1 }]); - XYAnalysis.renderChart('0', 0, 'A', 'B', 'C'); - - const config = Chart.mock.calls[0][1]; - const handler = config.options.plugins.tooltip.external; - - const mockEl = document.createElement('div'); - const mockChart = { - canvas: { parentNode: { querySelector: () => mockEl } }, - }; - - handler({ chart: mockChart, tooltip: { opacity: 0 } }); - expect(mockEl.style.opacity).toBe('0'); - }); - - test('getOrCreateTooltip creates element if missing', () => { - const mockParent = document.createElement('div'); - const mockCanvas = document.createElement('canvas'); - mockParent.appendChild(mockCanvas); - - const chart = { canvas: mockCanvas }; - const tooltip = XYAnalysis.getOrCreateTooltip(chart); - - expect(tooltip).not.toBeNull(); - expect(tooltip.className).toBe('chartjs-tooltip'); - expect(mockParent.querySelector('.chartjs-tooltip')).not.toBeNull(); - }); - }); - - describe('Timeline Hover Plugin', () => { - test('Timeline hover plugin draws line on active tooltip', () => { - AppState.files = [ - { - startTime: 0, - availableSignals: ['SigA'], - signals: { SigA: [{ x: 0, y: 0 }] }, - }, - ]; - - XYAnalysis.renderTimeline(0, ['SigA']); - - const chartCall = Chart.mock.calls[0]; - const config = chartCall[1]; - const hoverPlugin = config.plugins.find((p) => p.id === 'xyHoverLine'); - - const mockCtx = { - save: jest.fn(), - beginPath: jest.fn(), - moveTo: jest.fn(), - lineTo: jest.fn(), - stroke: jest.fn(), - restore: jest.fn(), - lineWidth: 0, - strokeStyle: '', - }; - - const mockChart = { - ctx: mockCtx, - tooltip: { _active: [{ element: { x: 50 } }] }, - scales: { y: { top: 10, bottom: 100 } }, - }; - - hoverPlugin.afterDraw(mockChart); - - expect(mockCtx.beginPath).toHaveBeenCalled(); - expect(mockCtx.moveTo).toHaveBeenCalledWith(50, 10); - expect(mockCtx.lineTo).toHaveBeenCalledWith(50, 100); - expect(mockCtx.stroke).toHaveBeenCalled(); - }); - }); -}); diff --git a/tests/xyanalysis.suite.4test.js b/tests/xyanalysis.suite.4test.js deleted file mode 100644 index fe105e6..0000000 --- a/tests/xyanalysis.suite.4test.js +++ /dev/null @@ -1,200 +0,0 @@ -import { - jest, - describe, - test, - expect, - beforeEach, - afterEach, -} from '@jest/globals'; - -const mockChartInstance = { - destroy: jest.fn(), - update: jest.fn(), - resetZoom: jest.fn(), - canvas: { - parentNode: { - querySelector: jest.fn(), - appendChild: jest.fn(), - }, - offsetLeft: 0, - offsetTop: 0, - }, - data: { datasets: [] }, - options: { - plugins: { tooltip: { external: null } }, - scales: { x: {}, y: {} }, - }, -}; - -await jest.unstable_mockModule('chart.js', () => { - const MockChart = jest.fn(() => mockChartInstance); - MockChart.register = jest.fn(); - const MockTooltip = jest.fn(); - MockTooltip.positioners = {}; - return { - __esModule: true, - Chart: MockChart, - ScatterController: jest.fn(), - LineController: jest.fn(), - PointElement: jest.fn(), - LineElement: jest.fn(), - LinearScale: jest.fn(), - TimeScale: jest.fn(), - Legend: jest.fn(), - Tooltip: MockTooltip, - _adapters: { _date: {} }, - }; -}); - -await jest.unstable_mockModule('chartjs-adapter-date-fns', () => ({})); -await jest.unstable_mockModule('chartjs-plugin-zoom', () => ({ - default: { id: 'zoom' }, -})); -await jest.unstable_mockModule('../src/ui.js', () => ({ UI: {} })); -await jest.unstable_mockModule('../src/config.js', () => ({ - AppState: { files: [] }, -})); -await jest.unstable_mockModule('../src/palettemanager.js', () => ({ - PaletteManager: { getColorForSignal: jest.fn(() => '#ff0000') }, -})); - -const { XYAnalysis } = await import('../src/xyanalysis.js'); -const { AppState } = await import('../src/config.js'); -const { Chart, Tooltip } = await import('chart.js'); - -describe('XYAnalysis Suite 5', () => { - beforeEach(() => { - jest.clearAllMocks(); - AppState.files = []; - - // --- JSDOM innerText Hack --- - Object.defineProperty(HTMLElement.prototype, 'innerText', { - get() { - return this.textContent; - }, - set(value) { - this.textContent = value; - }, - configurable: true, - }); - // ----------------------------- - - document.body.innerHTML = ` - -
-
-
-
- -
- - `; - - HTMLCanvasElement.prototype.getContext = jest.fn(() => ({ - save: jest.fn(), - restore: jest.fn(), - beginPath: jest.fn(), - moveTo: jest.fn(), - lineTo: jest.fn(), - stroke: jest.fn(), - fill: jest.fn(), - fillRect: jest.fn(), - })); - }); - - afterEach(() => { - jest.restoreAllMocks(); - }); - - describe('Modal Logic', () => { - test('openXYModal adjusts split view styles', () => { - XYAnalysis.openXYModal(); - const split = document.getElementById('xySplitView'); - const timeline = document.getElementById('xyTimelineView'); - - expect(split.style.flex).toMatch(/^3/); - expect(timeline.style.flex).toMatch(/^1/); - }); - }); - - describe('Custom Tooltip Logic', () => { - test('renderChart configures external tooltip and it renders HTML correctly', () => { - AppState.files = [ - { - availableSignals: ['RPM', 'MAP', 'MAF'], - signals: {}, - }, - ]; - - jest - .spyOn(XYAnalysis, 'generateScatterData') - .mockReturnValue([{ x: 1, y: 1, z: 1 }]); - XYAnalysis.renderChart('0', 0, 'RPM', 'MAP', 'MAF'); - - const chartCall = Chart.mock.calls[0]; - const config = chartCall[1]; - const externalHandler = config.options.plugins.tooltip.external; - - const mockTooltipEl = document.createElement('div'); - mockTooltipEl.className = 'chartjs-tooltip'; - const mockTable = document.createElement('table'); - mockTooltipEl.appendChild(mockTable); - document.body.appendChild(mockTooltipEl); - - const mockChart = { - canvas: { - parentNode: { - querySelector: jest.fn(() => mockTooltipEl), - appendChild: jest.fn(), - }, - offsetLeft: 10, - offsetTop: 20, - }, - }; - - const context = { - chart: mockChart, - tooltip: { - opacity: 1, - caretX: 100, - caretY: 100, - options: { padding: 6, bodyFont: { string: '12px Arial' } }, - body: [{}], - dataPoints: [ - { - raw: { x: 1000, y: 1.5, z: 20.5 }, - }, - ], - }, - }; - - externalHandler(context); - - const rows = mockTable.querySelectorAll('tr'); - expect(rows.length).toBe(3); - - expect(rows[0].innerHTML).toContain('RPM: 1000.00'); - expect(rows[1].innerHTML).toContain('MAP: 1.50'); - expect(rows[2].innerHTML).toContain('MAF: 20.50'); - }); - - test('External tooltip hides when opacity is 0', () => { - jest - .spyOn(XYAnalysis, 'generateScatterData') - .mockReturnValue([{ x: 1, y: 1, z: 1 }]); - XYAnalysis.renderChart('0', 0, 'A', 'B', 'C'); - - const config = Chart.mock.calls[0][1]; - const handler = config.options.plugins.tooltip.external; - - const mockEl = document.createElement('div'); - const mockChart = { - canvas: { parentNode: { querySelector: () => mockEl } }, - }; - - handler({ chart: mockChart, tooltip: { opacity: 0 } }); - - expect(mockEl.style.opacity).toBe('0'); - }); - }); -}); diff --git a/tests/xyanalysis.test.js b/tests/xyanalysis.test.js new file mode 100644 index 0000000..5fac9d5 --- /dev/null +++ b/tests/xyanalysis.test.js @@ -0,0 +1,692 @@ +import { + jest, + describe, + test, + expect, + beforeEach, + afterEach, +} from '@jest/globals'; + +const mockChartInstance = { + destroy: jest.fn(), + update: jest.fn(), + resetZoom: jest.fn(), + draw: jest.fn(), + width: 1000, + scales: { x: { min: 0, max: 1000, getValueForPixel: jest.fn() } }, + data: { datasets: [] }, + canvas: { + parentNode: { + querySelector: jest.fn(), + appendChild: jest.fn(), + }, + offsetLeft: 0, + offsetTop: 0, + }, + options: { + plugins: { + datalabels: {}, + zoom: {}, + tooltip: { callbacks: {}, external: null }, + }, + scales: { x: { min: 0, max: 0, title: {} }, y: { title: {} } }, + }, +}; + +await jest.unstable_mockModule('chart.js', () => { + const MockChart = jest.fn(() => mockChartInstance); + MockChart.register = jest.fn(); + const MockTooltip = jest.fn(); + MockTooltip.positioners = {}; + return { + __esModule: true, + Chart: MockChart, + ScatterController: jest.fn(), + LineController: jest.fn(), + PointElement: jest.fn(), + LineElement: jest.fn(), + LinearScale: jest.fn(), + TimeScale: jest.fn(), + Legend: jest.fn(), + Tooltip: MockTooltip, + _adapters: { _date: {} }, + }; +}); + +await jest.unstable_mockModule('chartjs-adapter-date-fns', () => ({})); +await jest.unstable_mockModule('chartjs-plugin-zoom', () => ({ + default: { id: 'zoom' }, +})); +await jest.unstable_mockModule('../src/ui.js', () => ({ UI: {} })); +await jest.unstable_mockModule('../src/config.js', () => ({ + AppState: { files: [] }, +})); +await jest.unstable_mockModule('../src/palettemanager.js', () => ({ + PaletteManager: { getColorForSignal: jest.fn(() => '#ff0000') }, +})); + +const { XYAnalysis } = await import('../src/xyanalysis.js'); +const { AppState } = await import('../src/config.js'); +const { Chart, Tooltip } = await import('chart.js'); + +describe('XYAnalysis Comprehensive Tests', () => { + const originalCreateElement = document.createElement.bind(document); + + beforeEach(() => { + jest.clearAllMocks(); + AppState.files = []; + XYAnalysis.charts = [null, null]; + XYAnalysis.timelineChart = null; + XYAnalysis.currentFileIndex = undefined; + + jest.spyOn(document, 'createElement').mockImplementation((tagName) => { + const el = originalCreateElement(tagName); + Object.defineProperty(el, 'innerText', { + get() { + return this.textContent; + }, + set(value) { + this.textContent = value; + }, + configurable: true, + }); + return el; + }); + + document.body.innerHTML = ` + +
+
+ +
+
+ + + +
+ + + + + `; + + HTMLCanvasElement.prototype.getContext = jest.fn(() => ({ + save: jest.fn(), + restore: jest.fn(), + beginPath: jest.fn(), + moveTo: jest.fn(), + lineTo: jest.fn(), + stroke: jest.fn(), + fill: jest.fn(), + fillRect: jest.fn(), + measureText: jest.fn(() => ({ width: 0 })), + })); + + Chart.mockImplementation(() => mockChartInstance); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + const getInputValue = (id) => { + const container = document.getElementById(id); + return container && container.querySelector('input') + ? container.querySelector('input').value + : ''; + }; + + describe('Initialization', () => { + test('init registers Chart.js plugins', () => { + XYAnalysis.init(); + expect(Chart.register).toHaveBeenCalled(); + }); + + test('xyFixed positioner returns correct coordinates', () => { + XYAnalysis.init(); + const positioner = Tooltip.positioners.xyFixed; + const context = { chart: { chartArea: { top: 50, right: 500 } } }; + const pos = positioner.call(context, [], {}); + expect(pos).toEqual({ x: 490, y: 60 }); + }); + + test('xyFixed returns undefined if no chart', () => { + XYAnalysis.init(); + const positioner = Tooltip.positioners.xyFixed; + const pos = positioner.call({}, [], {}); + expect(pos).toBeUndefined(); + }); + }); + + describe('UI Interaction', () => { + test('openXYModal shows modal and adjusts split view styles', () => { + const modal = document.getElementById('xyModal'); + const split = document.getElementById('xySplitView'); + const timeline = document.getElementById('xyTimelineView'); + const spy = jest.spyOn(XYAnalysis, 'populateGlobalFileSelector'); + + XYAnalysis.openXYModal(); + + expect(modal.style.display).toBe('flex'); + expect(split.style.flex).toMatch(/^3/); + expect(timeline.style.flex).toMatch(/^1/); + expect(spy).toHaveBeenCalled(); + }); + + test('closeXYModal hides modal', () => { + const modal = document.getElementById('xyModal'); + modal.style.display = 'flex'; + XYAnalysis.closeXYModal(); + expect(modal.style.display).toBe('none'); + }); + + describe('Searchable Select Logic', () => { + test('Replaces SELECT element with custom DIV wrapper', () => { + const container = document.getElementById('xyX-0'); + container.outerHTML = + ''; + + XYAnalysis.createSearchableSelect('xyX-0', ['A', 'B'], 'A', jest.fn()); + + const newEl = document.getElementById('xyX-0'); + expect(newEl.tagName).toBe('DIV'); + expect(newEl.className).toContain('searchable-select-wrapper'); + expect(newEl.style.color).toBe('red'); + }); + + test('Filter logic shows "No signals found"', () => { + XYAnalysis.createSearchableSelect( + 'xyGlobalFile', + ['OptionA'], + '', + jest.fn() + ); + const container = document.getElementById('xyGlobalFile'); + const input = container.querySelector('input'); + const list = container.querySelector('.search-results-list'); + + input.value = 'XYZ'; + input.dispatchEvent(new Event('input')); + + expect(list.children.length).toBe(1); + expect(list.children[0].innerText).toBe('No signals found'); + }); + + test('Clicking option updates value and hides list', () => { + const cb = jest.fn(); + XYAnalysis.createSearchableSelect('xyGlobalFile', ['OptionA'], '', cb); + const container = document.getElementById('xyGlobalFile'); + const input = container.querySelector('input'); + const list = container.querySelector('.search-results-list'); + + input.focus(); + const option = list.children[0]; + option.click(); + + expect(input.value).toBe('OptionA'); + expect(list.style.display).toBe('none'); + expect(cb).toHaveBeenCalledWith('OptionA'); + }); + + test('Clicking outside closes list', () => { + XYAnalysis.createSearchableSelect('xyGlobalFile', ['A'], '', jest.fn()); + const container = document.getElementById('xyGlobalFile'); + const input = container.querySelector('input'); + const list = container.querySelector('.search-results-list'); + + input.focus(); + expect(list.style.display).toBe('block'); + document.body.click(); + expect(list.style.display).toBe('none'); + }); + + test('getInputValue handles missing input gracefully', () => { + document.getElementById('xyX-0').innerHTML = '
Broken
'; + const val = XYAnalysis.getInputValue('xyX-0'); + expect(val).toBe(''); + }); + + test('getInputValue handles raw SELECT element', () => { + const container = document.getElementById('xyX-0'); + container.outerHTML = + ''; + const val = XYAnalysis.getInputValue('xyX-0'); + expect(val).toBe('A'); + }); + }); + + test('populateGlobalFileSelector fills searchable list and triggers change', () => { + AppState.files = [{ name: 'Trip A', availableSignals: [], signals: {} }]; + const spy = jest.spyOn(XYAnalysis, 'onFileChange'); + + XYAnalysis.populateGlobalFileSelector(); + + const container = document.getElementById('xyGlobalFile'); + const input = container.querySelector('input'); + const list = container.querySelector('.search-results-list'); + + expect(input.value).toBe('Trip A'); + + input.value = ''; + input.dispatchEvent(new Event('input')); + + expect(list.children[0].innerText).toBe('Trip A'); + expect(spy).toHaveBeenCalled(); + }); + + test('onFileChange populates axis selectors with defaults', () => { + AppState.files = [ + { + name: 'Trip A', + availableSignals: [ + 'Engine Rpm', + 'Intake Manifold Pressure', + 'Air Mass', + ], + signals: { + 'Engine Rpm': [], + 'Intake Manifold Pressure': [], + 'Air Mass': [], + }, + }, + ]; + XYAnalysis.populateGlobalFileSelector(); + const updateTimelineSpy = jest.spyOn(XYAnalysis, 'updateTimeline'); + + XYAnalysis.onFileChange(); + + expect(getInputValue('xyX-0')).toBe('Engine Rpm'); + expect(getInputValue('xyY-0')).toBe('Intake Manifold Pressure'); + expect(updateTimelineSpy).toHaveBeenCalled(); + }); + + test('onFileChange handles missing file gracefully', () => { + AppState.files = []; + XYAnalysis.currentFileIndex = 0; + expect(() => XYAnalysis.onFileChange()).not.toThrow(); + }); + }); + + describe('Data Processing', () => { + test('generateScatterData handles millisecond timestamp tolerance', () => { + AppState.files = [ + { + signals: { + X: [{ x: 200000, y: 1 }], + Y: [{ x: 200100, y: 2 }], + Z: [{ x: 200499, y: 3 }], + }, + }, + ]; + const result = XYAnalysis.generateScatterData(0, 'X', 'Y', 'Z'); + expect(result).toHaveLength(1); + expect(result[0]).toEqual({ x: 1, y: 2, z: 3 }); + }); + + test('generateScatterData skips points outside tolerance', () => { + AppState.files = [ + { + signals: { + X: [{ x: 1.0, y: 1 }], + Y: [{ x: 2.0, y: 2 }], + Z: [{ x: 1.0, y: 3 }], + }, + }, + ]; + const result = XYAnalysis.generateScatterData(0, 'X', 'Y', 'Z'); + expect(result).toHaveLength(0); + }); + + test('generateScatterData synchronizes lagging signals', () => { + AppState.files = [ + { + signals: { + X: [{ x: 50.0, y: 100 }], + Y: [ + { x: 1.0, y: 1 }, + { x: 2.0, y: 2 }, + { x: 50.0, y: 10 }, + ], + Z: [ + { x: 5.0, y: 5 }, + { x: 50.0, y: 20 }, + ], + }, + }, + ]; + const result = XYAnalysis.generateScatterData(0, 'X', 'Y', 'Z'); + expect(result[0]).toEqual({ x: 100, y: 10, z: 20 }); + }); + + test('getHeatColor gradient checks', () => { + expect(XYAnalysis.getHeatColor(10, 10, 10)).toBe( + 'hsla(240, 100%, 50%, 0.8)' + ); + expect(XYAnalysis.getHeatColor(0, 0, 100)).toContain('240'); + expect(XYAnalysis.getHeatColor(100, 0, 100)).toContain('0'); + }); + + describe('Legend Logic', () => { + test('updateLegend handles constant values', () => { + XYAnalysis.updateLegend('0', 10, 10, 'Constant'); + const legend = document.getElementById('xyLegend-0'); + const values = legend.querySelectorAll('.legend-values span'); + expect(values[0].innerText).toBe('10.0'); + expect(values[4].innerText).toBe('10.0'); + }); + + test('updateLegend creates gradient structure', () => { + XYAnalysis.updateLegend('0', 0, 100, 'Label'); + const legend = document.getElementById('xyLegend-0'); + expect(legend.querySelector('.gradient-bar')).not.toBeNull(); + expect(legend.querySelector('.z-axis-label').innerText).toBe('Label'); + }); + + test('updateLegend returns early if element missing', () => { + document.getElementById('xyLegend-0').remove(); + expect(() => XYAnalysis.updateLegend('0', 0, 10, 'L')).not.toThrow(); + }); + }); + }); + + describe('Scatter Chart Rendering', () => { + test('renderChart handles empty data gracefully', () => { + jest.spyOn(XYAnalysis, 'generateScatterData').mockReturnValue([]); + XYAnalysis.renderChart('0', 0, 'A', 'B', 'C'); + expect(Chart).toHaveBeenCalledTimes(0); + }); + + test('renderChart configures Axis Titles and Zoom', () => { + AppState.files = [{ availableSignals: ['X', 'Y', 'Z'], signals: {} }]; + jest + .spyOn(XYAnalysis, 'generateScatterData') + .mockReturnValue([{ x: 1, y: 1, z: 1 }]); + + XYAnalysis.renderChart('0', 0, 'X', 'Y', 'Z'); + + const config = Chart.mock.calls[0][1]; + expect(config.options.scales.x.title.text).toBe('X'); + expect(config.options.scales.y.title.text).toBe('Y'); + expect(config.options.plugins.zoom.zoom.wheel.enabled).toBe(true); + }); + + test('Scatter chart uses external tooltip handler', () => { + jest + .spyOn(XYAnalysis, 'generateScatterData') + .mockReturnValue([{ x: 1, y: 2, z: 3 }]); + AppState.files = [{ availableSignals: ['A', 'B', 'C'], signals: {} }]; + + XYAnalysis.renderChart('0', 0, 'A', 'B', 'C'); + + const config = Chart.mock.calls[0][1]; + const tooltipConfig = config.options.plugins.tooltip; + expect(tooltipConfig.enabled).toBe(false); + expect(typeof tooltipConfig.external).toBe('function'); + }); + + test('Custom Tooltip: Renders HTML correctly', () => { + AppState.files = [ + { availableSignals: ['RPM', 'MAP', 'MAF'], signals: {} }, + ]; + jest + .spyOn(XYAnalysis, 'generateScatterData') + .mockReturnValue([{ x: 1, y: 1, z: 1 }]); + + XYAnalysis.renderChart('0', 0, 'RPM', 'MAP', 'MAF'); + + const config = Chart.mock.calls[0][1]; + const externalHandler = config.options.plugins.tooltip.external; + + const mockTooltipEl = document.createElement('div'); + mockTooltipEl.className = 'chartjs-tooltip'; + const mockTable = document.createElement('table'); + mockTooltipEl.appendChild(mockTable); + document.body.appendChild(mockTooltipEl); + + const mockChart = { + canvas: { + parentNode: { querySelector: jest.fn(() => mockTooltipEl) }, + offsetLeft: 10, + offsetTop: 20, + }, + }; + + const context = { + chart: mockChart, + tooltip: { + opacity: 1, + caretX: 100, + caretY: 100, + options: { padding: 6, bodyFont: { string: '12px Arial' } }, + body: [{}], + dataPoints: [{ raw: { x: 1000, y: 1.5, z: 20.5 } }], + }, + }; + + externalHandler(context); + + const rows = mockTable.querySelectorAll('tr'); + expect(rows.length).toBe(3); + expect(rows[0].innerHTML).toContain('RPM: 1000.00'); + expect(rows[1].innerHTML).toContain('MAP: 1.50'); + const colorDot = rows[0].querySelector('span'); + expect(colorDot.style.background).toBe('rgb(255, 0, 0)'); + }); + + test('Custom Tooltip: Hides when opacity is 0', () => { + jest + .spyOn(XYAnalysis, 'generateScatterData') + .mockReturnValue([{ x: 1, y: 1, z: 1 }]); + XYAnalysis.renderChart('0', 0, 'A', 'B', 'C'); + + const handler = Chart.mock.calls[0][1].options.plugins.tooltip.external; + const mockEl = document.createElement('div'); + const mockChart = { + canvas: { parentNode: { querySelector: () => mockEl } }, + }; + + handler({ chart: mockChart, tooltip: { opacity: 0 } }); + expect(mockEl.style.opacity).toBe('0'); + }); + + test('getOrCreateTooltip creates element if missing', () => { + const mockParent = document.createElement('div'); + const mockCanvas = document.createElement('canvas'); + mockParent.appendChild(mockCanvas); + + const chart = { canvas: mockCanvas }; + const tooltip = XYAnalysis.getOrCreateTooltip(chart); + + expect(tooltip.className).toBe('chartjs-tooltip'); + expect(mockParent.querySelector('.chartjs-tooltip')).not.toBeNull(); + }); + + test('resetAllZooms resets all charts', () => { + XYAnalysis.charts = [mockChartInstance, null]; + XYAnalysis.timelineChart = mockChartInstance; + XYAnalysis.resetAllZooms(); + expect(mockChartInstance.resetZoom).toHaveBeenCalledTimes(2); + }); + }); + + describe('Timeline Integration', () => { + test('renderTimeline creates chart with normalized data', () => { + AppState.files = [ + { + startTime: 1000, + availableSignals: ['RPM', 'Boost'], + signals: { + RPM: [ + { x: 1000, y: 0 }, + { x: 2000, y: 6000 }, + ], + Boost: [ + { x: 1000, y: 0 }, + { x: 2000, y: 1.5 }, + ], + }, + }, + ]; + XYAnalysis.renderTimeline(0, ['RPM', 'Boost']); + + const config = Chart.mock.calls[0][1]; + expect(config.data.datasets.length).toBe(2); + const rpmData = config.data.datasets.find((d) => d.label === 'RPM').data; + expect(rpmData[0].y).toBeCloseTo(0); + expect(rpmData[1].y).toBeCloseTo(1); + }); + + test('Timeline tooltip returns original values', () => { + AppState.files = [ + { + startTime: 0, + availableSignals: ['S1'], + signals: { S1: [{ x: 0, y: 50 }] }, + }, + ]; + XYAnalysis.renderTimeline(0, ['S1']); + + const callback = + Chart.mock.calls[0][1].options.plugins.tooltip.callbacks.label; + const text = callback({ + dataset: { label: 'S1' }, + raw: { originalValue: 50.1234 }, + }); + expect(text).toBe('S1: 50.12'); + }); + + test('renderTimeline uses PaletteManager for colors', () => { + AppState.files = [ + { + startTime: 0, + availableSignals: ['SigA'], + signals: { SigA: [{ x: 0, y: 0 }] }, + }, + ]; + XYAnalysis.renderTimeline(0, ['SigA']); + const config = Chart.mock.calls[0][1]; + expect(config.data.datasets[0].borderColor).toBe('#ff0000'); + }); + + test('renderTimeline handles flatline signals (avoid divide-by-zero)', () => { + AppState.files = [ + { + startTime: 0, + availableSignals: ['Flat'], + signals: { + Flat: [ + { x: 0, y: 100 }, + { x: 1, y: 100 }, + ], + }, + }, + ]; + XYAnalysis.renderTimeline(0, ['Flat']); + const data = Chart.mock.calls[0][1].data.datasets[0].data; + expect(data[0].y).toBe(0); + }); + + test('renderTimeline skips signals not found in file', () => { + AppState.files = [ + { + startTime: 0, + availableSignals: ['A'], + signals: { A: [{ x: 0, y: 0 }] }, + }, + ]; + XYAnalysis.renderTimeline(0, ['A', 'Missing']); + expect(Chart.mock.calls[0][1].data.datasets).toHaveLength(1); + }); + + test('renderTimeline returns early if canvas missing', () => { + document.getElementById('xyTimelineCanvas').remove(); + XYAnalysis.renderTimeline(0, ['RPM']); + expect(Chart).not.toHaveBeenCalled(); + }); + + test('Timeline Hover Plugin draws line', () => { + AppState.files = [ + { + startTime: 0, + availableSignals: ['S1'], + signals: { S1: [{ x: 0, y: 0 }] }, + }, + ]; + XYAnalysis.renderTimeline(0, ['S1']); + + const config = Chart.mock.calls[0][1]; + const hoverPlugin = config.plugins.find((p) => p.id === 'xyHoverLine'); + + const mockCtx = { + save: jest.fn(), + beginPath: jest.fn(), + moveTo: jest.fn(), + lineTo: jest.fn(), + stroke: jest.fn(), + restore: jest.fn(), + lineWidth: 0, + strokeStyle: '', + }; + const mockChart = { + ctx: mockCtx, + tooltip: { _active: [{ element: { x: 50 } }] }, + scales: { y: { top: 10, bottom: 100 } }, + }; + + hoverPlugin.afterDraw(mockChart); + expect(mockCtx.moveTo).toHaveBeenCalledWith(50, 10); + expect(mockCtx.lineTo).toHaveBeenCalledWith(50, 100); + expect(mockCtx.stroke).toHaveBeenCalled(); + }); + + test('updateTimeline aggregates signals from all selectors', () => { + const setVal = (id, val) => { + document.getElementById(id).innerHTML = ``; + }; + setVal('xyX-0', 'S1'); + setVal('xyY-0', 'S2'); + setVal('xyZ-0', 'S1'); + setVal('xyX-1', 'S3'); + setVal('xyY-1', ''); + setVal('xyZ-1', ''); + + AppState.files = [ + { + startTime: 0, + availableSignals: ['S1', 'S2', 'S3'], + signals: { S1: [], S2: [], S3: [] }, + }, + ]; + XYAnalysis.currentFileIndex = 0; + + const spy = jest.spyOn(XYAnalysis, 'renderTimeline'); + XYAnalysis.updateTimeline(); + + expect(spy).toHaveBeenCalledWith( + expect.anything(), + expect.arrayContaining(['S1', 'S2', 'S3']) + ); + }); + + test('plot triggers both scatter and timeline updates', () => { + const scatterSpy = jest + .spyOn(XYAnalysis, 'renderChart') + .mockImplementation(() => {}); + const timelineSpy = jest + .spyOn(XYAnalysis, 'updateTimeline') + .mockImplementation(() => {}); + + document.getElementById('xyX-0').innerHTML = ``; + document.getElementById('xyY-0').innerHTML = ``; + document.getElementById('xyZ-0').innerHTML = ``; + + AppState.files = [{ name: 'F', startTime: 0, signals: {} }]; + XYAnalysis.currentFileIndex = 0; + + XYAnalysis.plot('0'); + + expect(scatterSpy).toHaveBeenCalled(); + expect(timelineSpy).toHaveBeenCalled(); + }); + }); +}); From e74ccb8b4ef40a700d69386a84f0906e94fdb7ee Mon Sep 17 00:00:00 2001 From: Tomek Zebrowski Date: Fri, 23 Jan 2026 08:57:47 +0100 Subject: [PATCH 6/7] feat: convert xyanalysis to class --- src/xyanalysis.js | 122 +++++++++++++++++++++++++-------------- tests/xyanalysis.test.js | 107 ++++++++++++++++++++++++++++++++++ 2 files changed, 185 insertions(+), 44 deletions(-) diff --git a/src/xyanalysis.js b/src/xyanalysis.js index 2f202a5..57bccd0 100644 --- a/src/xyanalysis.js +++ b/src/xyanalysis.js @@ -16,9 +16,34 @@ import { import zoomPlugin from 'chartjs-plugin-zoom'; import 'chartjs-adapter-date-fns'; -export const XYAnalysis = { - charts: [null, null], - timelineChart: null, +class XYAnalysisClass { + #charts = [null, null]; + #timelineChart = null; + #currentFileIndex = 0; + + get charts() { + return this.#charts; + } + + set charts(value) { + this.#charts = value; + } + + get timelineChart() { + return this.#timelineChart; + } + + set timelineChart(value) { + this.#timelineChart = value; + } + + get currentFileIndex() { + return this.#currentFileIndex; + } + + set currentFileIndex(value) { + this.#currentFileIndex = value; + } init() { Tooltip.positioners.xyFixed = function (elements, eventPosition) { @@ -41,7 +66,7 @@ export const XYAnalysis = { Legend, zoomPlugin ); - }, + } openXYModal() { const modal = document.getElementById('xyModal'); @@ -56,12 +81,12 @@ export const XYAnalysis = { } this.populateGlobalFileSelector(); - }, + } closeXYModal() { const modal = document.getElementById('xyModal'); if (modal) modal.style.display = 'none'; - }, + } populateGlobalFileSelector() { const fileNames = AppState.files.map((f) => f.name); @@ -69,27 +94,29 @@ export const XYAnalysis = { this.createSearchableSelect( 'xyGlobalFile', fileNames, - fileNames[this.currentFileIndex || 0] || '', + fileNames[this.#currentFileIndex || 0] || '', (selectedName) => { const idx = AppState.files.findIndex((f) => f.name === selectedName); - this.currentFileIndex = idx; + this.#currentFileIndex = idx; this.onFileChange(); } ); - if (AppState.files.length > 0 && this.currentFileIndex === undefined) { - this.currentFileIndex = 0; + if (AppState.files.length > 0 && this.#currentFileIndex === undefined) { + this.#currentFileIndex = 0; + this.onFileChange(); + } else if (AppState.files.length > 0) { this.onFileChange(); } - }, + } onFileChange() { const fileIdx = - this.currentFileIndex !== undefined ? this.currentFileIndex : 0; + this.#currentFileIndex !== undefined ? this.#currentFileIndex : 0; const file = AppState.files[fileIdx]; if (!file) return; - const signals = file.availableSignals.sort(); + const signals = [...(file.availableSignals || [])].sort(); ['0', '1'].forEach((panelIdx) => { let defX = 'Engine Rpm'; @@ -126,7 +153,7 @@ export const XYAnalysis = { this.plot('0'); this.plot('1'); this.updateTimeline(); - }, + } createSearchableSelect(elementId, options, defaultValue, onChangeCallback) { let container = document.getElementById(elementId); @@ -167,13 +194,13 @@ export const XYAnalysis = { const noRes = document.createElement('div'); noRes.className = 'search-option'; noRes.style.color = '#999'; - noRes.innerText = 'No signals found'; + noRes.textContent = 'No signals found'; list.appendChild(noRes); } else { filtered.forEach((opt) => { const item = document.createElement('div'); item.className = 'search-option'; - item.innerText = opt; + item.textContent = opt; item.onclick = () => { input.value = opt; input.setAttribute('data-selected-value', opt); @@ -186,7 +213,7 @@ export const XYAnalysis = { }; input.onfocus = () => { - renderList(input.value); + renderList(''); list.style.display = 'block'; }; @@ -195,15 +222,20 @@ export const XYAnalysis = { list.style.display = 'block'; }; - document.addEventListener('click', (e) => { + const closeListener = (e) => { + if (!document.body.contains(container)) { + document.removeEventListener('click', closeListener); + return; + } if (!container.contains(e.target)) { list.style.display = 'none'; } - }); + }; + document.addEventListener('click', closeListener); container.appendChild(input); container.appendChild(list); - }, + } getInputValue(containerId) { const container = document.getElementById(containerId); @@ -211,11 +243,11 @@ export const XYAnalysis = { if (container.tagName === 'SELECT') return container.value; const input = container.querySelector('input'); return input ? input.value : ''; - }, + } plot(panelIdx) { const fileIdx = - this.currentFileIndex !== undefined ? this.currentFileIndex : 0; + this.#currentFileIndex !== undefined ? this.#currentFileIndex : 0; const xSig = this.getInputValue(`xyX-${panelIdx}`); const ySig = this.getInputValue(`xyY-${panelIdx}`); @@ -225,16 +257,16 @@ export const XYAnalysis = { this.renderChart(panelIdx, fileIdx, xSig, ySig, zSig); this.updateTimeline(); - }, + } resetAllZooms() { - this.charts.forEach((c) => c?.resetZoom()); - if (this.timelineChart) this.timelineChart.resetZoom(); - }, + this.#charts.forEach((c) => c?.resetZoom()); + if (this.#timelineChart) this.#timelineChart.resetZoom(); + } updateTimeline() { const fileIdx = - this.currentFileIndex !== undefined ? this.currentFileIndex : 0; + this.#currentFileIndex !== undefined ? this.#currentFileIndex : 0; const signals = new Set(); ['0', '1'].forEach((idx) => { @@ -248,7 +280,7 @@ export const XYAnalysis = { const uniqueSignals = Array.from(signals).filter((s) => s); this.renderTimeline(fileIdx, uniqueSignals); - }, + } renderTimeline(fileIdx, signalNames) { const canvas = document.getElementById('xyTimelineCanvas'); @@ -257,7 +289,7 @@ export const XYAnalysis = { const ctx = canvas.getContext('2d'); const file = AppState.files[fileIdx]; - if (this.timelineChart) this.timelineChart.destroy(); + if (this.#timelineChart) this.#timelineChart.destroy(); if (!file || signalNames.length === 0) return; const datasets = signalNames @@ -278,7 +310,7 @@ export const XYAnalysis = { const color = PaletteManager.getColorForSignal( fileIdx, - file.availableSignals.indexOf(sigName) + (file.availableSignals || []).indexOf(sigName) ); return { @@ -325,7 +357,7 @@ export const XYAnalysis = { const textColor = isDark ? '#eee' : '#333'; const gridColor = isDark ? 'rgba(255,255,255,0.1)' : 'rgba(0,0,0,0.1)'; - this.timelineChart = new Chart(ctx, { + this.#timelineChart = new Chart(ctx, { type: 'line', data: { datasets }, plugins: [hoverLinePlugin], @@ -379,7 +411,7 @@ export const XYAnalysis = { }, }, }); - }, + } renderChart(panelIdx, fileIdx, signalX, signalY, signalZ) { const canvasId = `xyCanvas-${panelIdx}`; @@ -387,8 +419,8 @@ export const XYAnalysis = { if (!canvas) return; const ctx = canvas.getContext('2d'); - if (this.charts[panelIdx]) { - this.charts[panelIdx].destroy(); + if (this.#charts[panelIdx]) { + this.#charts[panelIdx].destroy(); } const data = this.generateScatterData(fileIdx, signalX, signalY, signalZ); @@ -405,7 +437,7 @@ export const XYAnalysis = { const color = isDark ? '#eee' : '#333'; const grid = isDark ? 'rgba(255,255,255,0.1)' : 'rgba(0,0,0,0.1)'; - this.charts[panelIdx] = new Chart(ctx, { + this.#charts[panelIdx] = new Chart(ctx, { type: 'scatter', data: { datasets: [ @@ -490,7 +522,7 @@ export const XYAnalysis = { const tdText = document.createElement('td'); tdText.style.borderWidth = 0; tdText.style.color = '#fff'; - tdText.innerText = `${label}: ${value.toFixed(2)}`; + tdText.textContent = `${label}: ${value.toFixed(2)}`; tr.appendChild(tdColor); tr.appendChild(tdText); @@ -528,7 +560,7 @@ export const XYAnalysis = { }, }, }); - }, + } getOrCreateTooltip(chart) { let tooltipEl = chart.canvas.parentNode.querySelector( @@ -556,7 +588,7 @@ export const XYAnalysis = { } return tooltipEl; - }, + } updateLegend(panelIdx, min, max, zLabel) { const legend = document.getElementById(`xyLegend-${panelIdx}`); @@ -568,7 +600,7 @@ export const XYAnalysis = { labelContainer.className = 'legend-label-container'; const labelSpan = document.createElement('span'); labelSpan.className = 'z-axis-label'; - labelSpan.innerText = zLabel || 'Z-Axis'; + labelSpan.textContent = zLabel || 'Z-Axis'; labelContainer.appendChild(labelSpan); legend.appendChild(labelContainer); @@ -583,11 +615,11 @@ export const XYAnalysis = { const pct = 1 - i / (steps - 1); const val = min + (max - min) * pct; const valSpan = document.createElement('span'); - valSpan.innerText = val.toFixed(1); + valSpan.textContent = val.toFixed(1); valuesContainer.appendChild(valSpan); } legend.appendChild(valuesContainer); - }, + } generateScatterData(fileIndex, signalXName, signalYName, signalZName) { const file = AppState.files[fileIndex]; @@ -635,7 +667,7 @@ export const XYAnalysis = { } }); return scatterPoints; - }, + } getHeatColor(value, min, max) { if (min === max) return 'hsla(240, 100%, 50%, 0.8)'; @@ -643,5 +675,7 @@ export const XYAnalysis = { ratio = Math.max(0, Math.min(1, ratio)); const hue = (1 - ratio) * 240; return `hsla(${hue}, 100%, 50%, 0.8)`; - }, -}; + } +} + +export const XYAnalysis = new XYAnalysisClass(); diff --git a/tests/xyanalysis.test.js b/tests/xyanalysis.test.js index 5fac9d5..99804bd 100644 --- a/tests/xyanalysis.test.js +++ b/tests/xyanalysis.test.js @@ -194,6 +194,32 @@ describe('XYAnalysis Comprehensive Tests', () => { expect(newEl.style.color).toBe('red'); }); + test('Focusing input shows all options regardless of current value', () => { + const options = ['Engine Rpm', 'Speed', 'Boost']; + const defaultValue = 'Speed'; + + XYAnalysis.createSearchableSelect( + 'xyGlobalFile', + options, + defaultValue, + jest.fn() + ); + + const container = document.getElementById('xyGlobalFile'); + const input = container.querySelector('input'); + const list = container.querySelector('.search-results-list'); + + expect(input.value).toBe('Speed'); + + input.focus(); + + expect(list.children.length).toBe(3); + expect(list.children[0].textContent).toBe('Engine Rpm'); + expect(list.children[1].textContent).toBe('Speed'); + expect(list.children[2].textContent).toBe('Boost'); + expect(list.style.display).toBe('block'); + }); + test('Filter logic shows "No signals found"', () => { XYAnalysis.createSearchableSelect( 'xyGlobalFile', @@ -689,4 +715,85 @@ describe('XYAnalysis Comprehensive Tests', () => { expect(timelineSpy).toHaveBeenCalled(); }); }); + + describe('Edge Cases & Full Coverage', () => { + test('getHeatColor handles values outside min/max range', () => { + // Value below min (0) should be clamped to min -> Hue 240 (Blue) + expect(XYAnalysis.getHeatColor(-50, 0, 100)).toContain('240'); + // Value above max (100) should be clamped to max -> Hue 0 (Red) + expect(XYAnalysis.getHeatColor(150, 0, 100)).toContain('0'); + }); + + test('renderChart destroys existing chart before creating new one', () => { + const destroySpy = jest.fn(); + XYAnalysis.charts = [{ destroy: destroySpy }, null]; + + AppState.files = [{ availableSignals: ['A', 'B', 'C'], signals: {} }]; + jest + .spyOn(XYAnalysis, 'generateScatterData') + .mockReturnValue([{ x: 1, y: 1, z: 1 }]); + + XYAnalysis.renderChart('0', 0, 'A', 'B', 'C'); + + expect(destroySpy).toHaveBeenCalled(); + }); + + test('renderTimeline destroys existing chart', () => { + const destroySpy = jest.fn(); + XYAnalysis.timelineChart = { destroy: destroySpy }; + + AppState.files = [ + { + startTime: 0, + availableSignals: ['A'], + signals: { A: [{ x: 0, y: 0 }] }, + }, + ]; + XYAnalysis.renderTimeline(0, ['A']); + + expect(destroySpy).toHaveBeenCalled(); + }); + + test('renderTimeline handles missing data for valid signal', () => { + // Signal is in availableSignals but null in signals map + AppState.files = [ + { + startTime: 0, + availableSignals: ['GhostSignal'], + signals: { GhostSignal: null }, + }, + ]; + + XYAnalysis.renderTimeline(0, ['GhostSignal']); + + const config = Chart.mock.calls[0][1]; + expect(config.data.datasets.length).toBe(0); // Should be filtered out + }); + + test('Event listener cleanup when element is removed from DOM', () => { + XYAnalysis.createSearchableSelect('xyGlobalFile', ['A'], '', jest.fn()); + const container = document.getElementById('xyGlobalFile'); + const list = container.querySelector('.search-results-list'); + + // Remove container from DOM to trigger cleanup logic + container.remove(); + + // Click anywhere + document.body.click(); + + // Should verify that no errors occurred and list state didn't change (implied coverage) + expect(list).toBeDefined(); + }); + + test('plot returns early if inputs are missing', () => { + const renderSpy = jest.spyOn(XYAnalysis, 'renderChart'); + + // Only set X, leave Y and Z empty + document.getElementById('xyX-0').innerHTML = ``; + + XYAnalysis.plot('0'); + + expect(renderSpy).not.toHaveBeenCalled(); + }); + }); }); From ffd378985b9b598aa552df71bcfbc67e6a8dde76 Mon Sep 17 00:00:00 2001 From: Tomek Zebrowski Date: Fri, 23 Jan 2026 09:05:51 +0100 Subject: [PATCH 7/7] fix: temp fix for dark-mode --- src/style.css | 24 ++++++++++++++++++------ 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/src/style.css b/src/style.css index f0e0b2b..e999ece 100644 --- a/src/style.css +++ b/src/style.css @@ -34,12 +34,12 @@ body.dark-theme { --chart-canvas-bg: #1a1a1a; --text-main: #333; --text-muted: #666; - --card-bg: #1e1e1e; - --sidebar-bg: #252525; - --border-color: #333; - --input-bg: #2d2d2d; - --input-border: #444; - --text-color: #eee; + /* --card-bg: #1e1e1e; */ + /* --sidebar-bg: #252525; */ + /* --border-color: #333; */ + /* --input-bg: #2d2d2d; */ + /* --input-border: #444; */ + /* --text-color: #eee; */ } body.dark-theme .sidebar-left, @@ -830,6 +830,18 @@ input[type='range']::-webkit-slider-thumb { animation: modalPopIn 0.3s cubic-bezier(0.34, 1.56, 0.64, 1); } +#xyModal { + --card-bg: #fff; + --text-main: #333; + --text-muted: #666; + --text-color: #333; + --input-bg: #fff; + --input-border: #ddd; + --border-color: #ddd; + --chart-canvas-bg: #fff; + --active-bg: #f0f0f0; +} + #xyModal .modal-content { max-width: 95vw !important; height: 90vh !important;